feat: split client tooling and add OAuth server login
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type CharAwakeService struct {
|
||||
design *gamedata.CharAwakeDesign
|
||||
collection *CollectionStore
|
||||
characters *CharacterStore
|
||||
inventory *Inventory
|
||||
wallet *Wallet
|
||||
}
|
||||
|
||||
func NewCharAwakeService(design *gamedata.CharAwakeDesign, collection *CollectionStore, characters *CharacterStore, inventory *Inventory, wallet *Wallet) (*CharAwakeService, error) {
|
||||
if design == nil || collection == nil || characters == nil || inventory == nil || wallet == nil {
|
||||
return nil, errors.New("player: incomplete character awakening service")
|
||||
}
|
||||
for uniqueID, progress := range collection.CharAwakeStates() {
|
||||
if err := design.ValidateProgress(uniqueID, progress.ImprintLevels, progress.IsAwake); err != nil {
|
||||
return nil, fmt.Errorf("player: invalid saved character awakening progress: %w", err)
|
||||
}
|
||||
}
|
||||
return &CharAwakeService{design: design, collection: collection, characters: characters, inventory: inventory, wallet: wallet}, nil
|
||||
}
|
||||
|
||||
func (s *CharAwakeService) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
switch path {
|
||||
case "/CharAwakeInfo":
|
||||
return s.info(request)
|
||||
case "/CharImprintLevelUp":
|
||||
return s.imprintLevelUp(request)
|
||||
case "/CharAwakeActive":
|
||||
return s.awakeActive(request)
|
||||
default:
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func validAwakeSequence(request []byte, path string) error {
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 {
|
||||
return fmt.Errorf("player: %s missing sequence", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CharAwakeService) info(request []byte) (int, []byte, bool, error) {
|
||||
if err := validAwakeSequence(request, "CharAwakeInfo"); err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
states := s.collection.CharAwakeStates()
|
||||
ids := make([]uint64, 0, len(states))
|
||||
for id := range states {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
var response []byte
|
||||
for _, id := range ids {
|
||||
progress := states[id]
|
||||
var entry []byte
|
||||
entry = wire.AppendVarint(entry, 1, id)
|
||||
for i, level := range progress.ImprintLevels {
|
||||
if level != 0 {
|
||||
entry = wire.AppendVarint(entry, 2+i, level)
|
||||
}
|
||||
}
|
||||
if progress.IsAwake {
|
||||
entry = wire.AppendVarint(entry, 5, 1)
|
||||
}
|
||||
response = wire.AppendBytes(response, 1, entry)
|
||||
}
|
||||
return 326, response, true, nil
|
||||
}
|
||||
|
||||
func decodeAwakeMaterials(request []byte, fieldNumber int) ([]Item, error) {
|
||||
var materials []Item
|
||||
err := wire.Walk(request, func(field wire.Field) error {
|
||||
if field.Number != fieldNumber {
|
||||
return nil
|
||||
}
|
||||
if field.Type != 2 {
|
||||
return errors.New("player: invalid character awakening material field")
|
||||
}
|
||||
var item Item
|
||||
if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil {
|
||||
return err
|
||||
}
|
||||
if item.Type == 0 || item.Count == 0 || (item.Type != 4 && (item.InvenIndex == 0 || item.ID == 0)) {
|
||||
return errors.New("player: incomplete character awakening material")
|
||||
}
|
||||
materials = append(materials, item)
|
||||
return nil
|
||||
})
|
||||
return materials, err
|
||||
}
|
||||
|
||||
func (s *CharAwakeService) imprintLevelUp(request []byte) (int, []byte, bool, error) {
|
||||
if err := validAwakeSequence(request, "CharImprintLevelUp"); err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
characterIndex, found, err := wire.Varint(request, 2)
|
||||
if err != nil || !found || characterIndex == 0 {
|
||||
return 0, nil, true, errors.New("player: CharImprintLevelUp missing character")
|
||||
}
|
||||
var targets []gamedata.CharImprintTarget
|
||||
err = wire.Walk(request, func(field wire.Field) error {
|
||||
if field.Number != 3 {
|
||||
return nil
|
||||
}
|
||||
if field.Type != 2 {
|
||||
return errors.New("player: invalid imprint target field")
|
||||
}
|
||||
var target gamedata.CharImprintTarget
|
||||
if err := decodeVarints(field.Value, map[int]*uint64{1: &target.Slot, 2: &target.TargetLevel}); err != nil {
|
||||
return err
|
||||
}
|
||||
if target.Slot == 0 || target.TargetLevel == 0 {
|
||||
return errors.New("player: incomplete imprint target")
|
||||
}
|
||||
targets = append(targets, target)
|
||||
return nil
|
||||
})
|
||||
if err != nil || len(targets) == 0 {
|
||||
if err == nil {
|
||||
err = errors.New("player: CharImprintLevelUp has no targets")
|
||||
}
|
||||
return 0, nil, true, err
|
||||
}
|
||||
materials, err := decodeAwakeMaterials(request, 4)
|
||||
if err != nil || len(materials) == 0 {
|
||||
if err == nil {
|
||||
err = errors.New("player: CharImprintLevelUp has no materials")
|
||||
}
|
||||
return 0, nil, true, err
|
||||
}
|
||||
character, found := s.characters.Find(characterIndex)
|
||||
if !found {
|
||||
return 0, nil, true, fmt.Errorf("player: unknown imprint character %d", characterIndex)
|
||||
}
|
||||
uniqueID, err := s.design.ValidateGrowthCompleted(character.ID, character.Level)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate imprint character growth: %w", err)
|
||||
}
|
||||
current, _ := s.collection.CharAwakeState(uniqueID)
|
||||
costs, levels, err := s.design.ImprintCosts(uniqueID, current.ImprintLevels, targets)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate imprint GameData: %w", err)
|
||||
}
|
||||
items, gold, err := s.validateCosts(costs, materials)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate imprint materials: %w", err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
if err := s.inventory.CanConsume(items); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate imprint inventory: %w", err)
|
||||
}
|
||||
}
|
||||
if gold != 0 && !s.wallet.CanSpendGold(gold) {
|
||||
return 0, nil, true, errors.New("player: insufficient gold for character imprint")
|
||||
}
|
||||
identity := "char-imprint:" + strconv.FormatUint(uniqueID, 10)
|
||||
for _, level := range levels {
|
||||
identity += ":" + strconv.FormatUint(level, 10)
|
||||
}
|
||||
if gold != 0 {
|
||||
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: spend imprint gold: %w", err)
|
||||
}
|
||||
}
|
||||
if len(items) != 0 {
|
||||
if err := s.inventory.Consume(items); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: consume imprint materials: %w", err)
|
||||
}
|
||||
}
|
||||
next := current
|
||||
next.ImprintLevels = levels
|
||||
if err := s.collection.UpdateCharAwake(uniqueID, current, next); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: persist character imprint: %w", err)
|
||||
}
|
||||
return 327, nil, true, nil
|
||||
}
|
||||
|
||||
func (s *CharAwakeService) awakeActive(request []byte) (int, []byte, bool, error) {
|
||||
if err := validAwakeSequence(request, "CharAwakeActive"); err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
characterIndex, found, err := wire.Varint(request, 2)
|
||||
if err != nil || !found || characterIndex == 0 {
|
||||
return 0, nil, true, errors.New("player: CharAwakeActive missing character")
|
||||
}
|
||||
materials, err := decodeAwakeMaterials(request, 3)
|
||||
if err != nil || len(materials) == 0 {
|
||||
if err == nil {
|
||||
err = errors.New("player: CharAwakeActive has no materials")
|
||||
}
|
||||
return 0, nil, true, err
|
||||
}
|
||||
character, found := s.characters.Find(characterIndex)
|
||||
if !found {
|
||||
return 0, nil, true, fmt.Errorf("player: unknown awakening character %d", characterIndex)
|
||||
}
|
||||
uniqueID, err := s.design.ValidateGrowthCompleted(character.ID, character.Level)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate awakening character growth: %w", err)
|
||||
}
|
||||
current, _ := s.collection.CharAwakeState(uniqueID)
|
||||
costs, err := s.design.AwakeCosts(uniqueID, current.ImprintLevels, current.IsAwake)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate awakening GameData: %w", err)
|
||||
}
|
||||
items, gold, err := s.validateCosts(costs, materials)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate awakening materials: %w", err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
if err := s.inventory.CanConsume(items); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate awakening inventory: %w", err)
|
||||
}
|
||||
}
|
||||
if gold != 0 && !s.wallet.CanSpendGold(gold) {
|
||||
return 0, nil, true, errors.New("player: insufficient gold for character awakening")
|
||||
}
|
||||
identity := "char-awake:" + strconv.FormatUint(uniqueID, 10)
|
||||
if gold != 0 {
|
||||
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: spend awakening gold: %w", err)
|
||||
}
|
||||
}
|
||||
if len(items) != 0 {
|
||||
if err := s.inventory.Consume(items); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: consume awakening materials: %w", err)
|
||||
}
|
||||
}
|
||||
next := current
|
||||
next.IsAwake = true
|
||||
if err := s.collection.UpdateCharAwake(uniqueID, current, next); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: persist character awakening: %w", err)
|
||||
}
|
||||
return 328, nil, true, nil
|
||||
}
|
||||
|
||||
func (s *CharAwakeService) validateCosts(costs []gamedata.CharAwakeCost, materials []Item) ([]Item, uint64, error) {
|
||||
want := make(map[[2]uint64]uint64, len(costs))
|
||||
for _, cost := range costs {
|
||||
key := [2]uint64{cost.Type, cost.ID}
|
||||
if cost.Count > ^uint64(0)-want[key] {
|
||||
return nil, 0, errors.New("character awakening 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}
|
||||
switch material.Type {
|
||||
case 4:
|
||||
if material.InvenIndex != 0 || material.ID != 0 || gold != 0 {
|
||||
return nil, 0, errors.New("invalid character awakening currency")
|
||||
}
|
||||
gold = material.Count
|
||||
case 8:
|
||||
items = append(items, material)
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("unsupported character awakening material type %d", material.Type)
|
||||
}
|
||||
if material.Count > ^uint64(0)-got[key] {
|
||||
return nil, 0, errors.New("submitted character awakening material overflow")
|
||||
}
|
||||
got[key] += material.Count
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
return nil, 0, fmt.Errorf("material kinds mismatch: request=%v GameData=%v", got, want)
|
||||
}
|
||||
for key, count := range want {
|
||||
if got[key] != count {
|
||||
return nil, 0, fmt.Errorf("material %d/%d=%d want %d", key[0], key[1], got[key], count)
|
||||
}
|
||||
}
|
||||
return items, gold, nil
|
||||
}
|
||||
|
||||
// Contributions exposes server-side derived stats for CharInfo, revival and
|
||||
// battle calculators. The client independently computes the same values from
|
||||
// CharAwakeInfo, so neither side relies on a persisted derived number.
|
||||
func (s *CharAwakeService) Contributions(character Character) ([]gamedata.StatContribution, error) {
|
||||
uniqueID, ok := s.design.CharacterUniqueID(character.ID)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
progress, _ := s.collection.CharAwakeState(uniqueID)
|
||||
return s.design.CharAwakeContributions(uniqueID, progress.ImprintLevels, progress.IsAwake)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func newCharAwakeHarness(t *testing.T) (*CharAwakeService, *CollectionStore, *Inventory, *Wallet, []Item) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
starter := &Starter{Version: "2.35.10"}
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
granted, err := inventory.GrantOnce("awake-test", []gamedata.BattleReward{
|
||||
{Type: 8, ID: 701, Count: 1}, {Type: 8, ID: 702, Count: 1}, {Type: 8, ID: 703, Count: 1}, {Type: 8, ID: 705, Count: 20},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection, err := OpenCollectionStore(testStore(filepath.Join(dir, "collection.json")), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters := &CharacterStore{characters: []Character{{InvenIndex: 77, ID: 354, Level: 100}}}
|
||||
character := gamedata.CharAwakeCharacter{UniqueCharID: 35, Active: true}
|
||||
for i := 0; i < 3; i++ {
|
||||
character.ImprintGrowth[i] = []gamedata.CharAwakeGrowth{{
|
||||
ID: uint64(i + 1), StatType: uint64(i*2 + 1), StatValue: float64(i + 1),
|
||||
Costs: []gamedata.CharAwakeCost{{Type: 8, ID: uint64(701 + i), Count: 1}, {Type: 4, Count: 100}},
|
||||
}}
|
||||
}
|
||||
character.AwakeGrowth = []gamedata.CharAwakeGrowth{
|
||||
{ID: 100, StatType: 4, StatValue: .12, Costs: []gamedata.CharAwakeCost{{Type: 8, ID: 705, Count: 20}, {Type: 4, Count: 500}}},
|
||||
{ID: 101, StatType: 14, StatValue: .1},
|
||||
}
|
||||
design := &gamedata.CharAwakeDesign{
|
||||
Characters: map[uint64]gamedata.CharAwakeCharacter{35: character},
|
||||
Stages: map[uint64]gamedata.CharAwakeCharacterStage{
|
||||
354: {UniqueCharID: 35, Grade: 5, GrowthGrade: 5, MaximumLevel: 100},
|
||||
},
|
||||
}
|
||||
service, err := NewCharAwakeService(design, collection, characters, inventory, wallet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service, collection, inventory, wallet, granted
|
||||
}
|
||||
|
||||
func awakeMaterial(item Item) []byte { return ItemWire(item) }
|
||||
|
||||
func TestCharImprintAndAwakePersistConsumeAndRestoreInfo(t *testing.T) {
|
||||
service, collection, inventory, wallet, granted := newCharAwakeHarness(t)
|
||||
request := wire.AppendVarint(nil, 1, 10)
|
||||
request = wire.AppendVarint(request, 2, 77)
|
||||
for slot := uint64(1); slot <= 3; slot++ {
|
||||
target := wire.AppendVarint(wire.AppendVarint(nil, 1, slot), 2, 1)
|
||||
request = wire.AppendBytes(request, 3, target)
|
||||
request = wire.AppendBytes(request, 4, awakeMaterial(granted[slot-1]))
|
||||
}
|
||||
request = wire.AppendBytes(request, 4, awakeMaterial(Item{Type: 4, Count: 300}))
|
||||
code, proto, handled, err := service.Handle("/CharImprintLevelUp", request)
|
||||
if err != nil || !handled || code != 327 || len(proto) != 0 {
|
||||
t.Fatalf("imprint code=%d proto=%x handled=%v err=%v", code, proto, handled, err)
|
||||
}
|
||||
progress, found := collection.CharAwakeState(35)
|
||||
if !found || progress.ImprintLevels != [3]uint64{1, 1, 1} || progress.IsAwake {
|
||||
t.Fatalf("imprint progress=%+v found=%v", progress, found)
|
||||
}
|
||||
if wallet.Snapshot().Gold != 700 || len(inventory.All()) != 1 {
|
||||
t.Fatalf("post-imprint wallet=%+v items=%+v", wallet.Snapshot(), inventory.All())
|
||||
}
|
||||
|
||||
request = wire.AppendVarint(nil, 1, 11)
|
||||
request = wire.AppendVarint(request, 2, 77)
|
||||
request = wire.AppendBytes(request, 3, awakeMaterial(granted[3]))
|
||||
request = wire.AppendBytes(request, 3, awakeMaterial(Item{Type: 4, Count: 500}))
|
||||
code, proto, handled, err = service.Handle("/CharAwakeActive", request)
|
||||
if err != nil || !handled || code != 328 || len(proto) != 0 {
|
||||
t.Fatalf("awake code=%d proto=%x handled=%v err=%v", code, proto, handled, err)
|
||||
}
|
||||
progress, _ = collection.CharAwakeState(35)
|
||||
if !progress.IsAwake || wallet.Snapshot().Gold != 200 || len(inventory.All()) != 0 {
|
||||
t.Fatalf("post-awake progress=%+v wallet=%+v items=%+v", progress, wallet.Snapshot(), inventory.All())
|
||||
}
|
||||
restarted, err := OpenCollectionStore(collection.store, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.collection = restarted
|
||||
progress, found = restarted.CharAwakeState(35)
|
||||
if !found || progress.ImprintLevels != [3]uint64{1, 1, 1} || !progress.IsAwake {
|
||||
t.Fatalf("restarted awakening progress=%+v found=%v", progress, found)
|
||||
}
|
||||
|
||||
code, proto, handled, err = service.Handle("/CharAwakeInfo", wire.AppendVarint(nil, 1, 12))
|
||||
if err != nil || !handled || code != 326 {
|
||||
t.Fatalf("info code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
var entry []byte
|
||||
if err := wire.Walk(proto, func(field wire.Field) error {
|
||||
if field.Number == 1 {
|
||||
entry = append([]byte(nil), field.Value...)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for field, want := range map[int]uint64{1: 35, 2: 1, 3: 1, 4: 1, 5: 1} {
|
||||
got, present, err := wire.Varint(entry, field)
|
||||
if err != nil || !present || got != want {
|
||||
t.Fatalf("CharAwakeDBInfo field %d=%d present=%v err=%v want=%d proto=%x", field, got, present, err, want, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharImprintRejectsClientCostMismatchBeforeMutation(t *testing.T) {
|
||||
service, collection, inventory, wallet, granted := newCharAwakeHarness(t)
|
||||
request := wire.AppendVarint(nil, 1, 20)
|
||||
request = wire.AppendVarint(request, 2, 77)
|
||||
request = wire.AppendBytes(request, 3, wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1))
|
||||
bad := granted[0]
|
||||
bad.Count = 2
|
||||
request = wire.AppendBytes(request, 4, awakeMaterial(bad))
|
||||
request = wire.AppendBytes(request, 4, awakeMaterial(Item{Type: 4, Count: 100}))
|
||||
if _, _, handled, err := service.Handle("/CharImprintLevelUp", request); !handled || err == nil {
|
||||
t.Fatalf("mismatched costs handled=%v err=%v", handled, err)
|
||||
}
|
||||
if _, found := collection.CharAwakeState(35); found || wallet.Snapshot().Gold != 1000 || len(inventory.All()) != 4 {
|
||||
t.Fatalf("rejected request mutated state: found=%v wallet=%+v items=%+v", found, wallet.Snapshot(), inventory.All())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,998 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/versionconfig"
|
||||
)
|
||||
|
||||
type CostumeUpgrade struct {
|
||||
InvenIndex uint64 `json:"inven_index"`
|
||||
CostumeID uint64 `json:"costume_id"`
|
||||
Before uint64 `json:"before"`
|
||||
After uint64 `json:"after"`
|
||||
SortID uint64 `json:"sort_id"`
|
||||
}
|
||||
|
||||
type CostumeExchange struct {
|
||||
InvenIndex uint64 `json:"inven_index,omitempty"`
|
||||
OriginalItemType uint64 `json:"original_item_type"`
|
||||
OriginalItemID uint64 `json:"original_item_id"`
|
||||
OriginalCount uint64 `json:"original_count"`
|
||||
ExchangeItemType uint64 `json:"exchange_item_type"`
|
||||
ExchangeItemID uint64 `json:"exchange_item_id,omitempty"`
|
||||
ExchangeCount uint64 `json:"exchange_count"`
|
||||
SortID uint64 `json:"sort_id"`
|
||||
}
|
||||
|
||||
type CollectionGrant struct {
|
||||
CharacterIndices []uint64 `json:"character_indices,omitempty"`
|
||||
CostumeIndices []uint64 `json:"costume_indices,omitempty"`
|
||||
Upgrades []CostumeUpgrade `json:"upgrades,omitempty"`
|
||||
Exchanges []CostumeExchange `json:"exchanges,omitempty"`
|
||||
ViewCostumeIDs []uint64 `json:"view_costume_ids"`
|
||||
GachaGroupID uint64 `json:"gacha_group_id,omitempty"`
|
||||
GachaPoint uint64 `json:"gacha_point,omitempty"`
|
||||
GachaFixed []GachaFixedState `json:"gacha_fixed,omitempty"`
|
||||
SelectionApplySortIDs []uint64 `json:"selection_apply_sort_ids,omitempty"`
|
||||
}
|
||||
|
||||
type GachaUserState struct {
|
||||
GroupID uint64 `json:"group_id"`
|
||||
Point uint64 `json:"point"`
|
||||
TotalBuyCount uint64 `json:"total_buy_count,omitempty"`
|
||||
OneFreePickCount uint64 `json:"one_free_pick_count,omitempty"`
|
||||
OneCashPickCount uint64 `json:"one_cash_pick_count,omitempty"`
|
||||
TenFreePickCount uint64 `json:"ten_free_pick_count,omitempty"`
|
||||
TenCashPickCount uint64 `json:"ten_cash_pick_count,omitempty"`
|
||||
ExchangeItemCount uint64 `json:"exchange_item_count,omitempty"`
|
||||
ExchangeMileageCount uint64 `json:"exchange_mileage_count,omitempty"`
|
||||
}
|
||||
|
||||
type GachaFixedState struct {
|
||||
FixedID uint64 `json:"fixed_id"`
|
||||
Type uint64 `json:"type"`
|
||||
Count uint64 `json:"count"`
|
||||
ApplySort int `json:"apply_sort_id"`
|
||||
}
|
||||
|
||||
type GachaPointExchange struct {
|
||||
GroupID uint64 `json:"group_id"`
|
||||
Count uint64 `json:"count"`
|
||||
}
|
||||
|
||||
type GachaPurchase struct {
|
||||
Group gamedata.GachaGroupDesign
|
||||
BuyType uint64
|
||||
DailyKey string
|
||||
DailyLimit uint64
|
||||
CompletionGrant string
|
||||
Fixed []GachaFixedState
|
||||
SelectionApplySortIDs []uint64
|
||||
StepUpGroupID uint64
|
||||
StepUpStep uint64
|
||||
}
|
||||
|
||||
type GachaSelection struct {
|
||||
GroupID uint64 `json:"group_id"`
|
||||
Slot uint64 `json:"slot"`
|
||||
ItemID uint64 `json:"item_id"`
|
||||
}
|
||||
|
||||
type GachaSelectionChangeCount struct {
|
||||
GroupID uint64 `json:"group_id"`
|
||||
Count uint64 `json:"count"`
|
||||
}
|
||||
|
||||
// CharAwakeProgress is account-wide character state keyed by CharTable's
|
||||
// UniqueCharId. Promotion changes CharTable.Id, but never this identity.
|
||||
type CharAwakeProgress struct {
|
||||
ImprintLevels [3]uint64 `json:"imprint_levels"`
|
||||
IsAwake bool `json:"is_awake"`
|
||||
}
|
||||
|
||||
// FirstGachaCompletedIdentity is the persisted account flag represented in
|
||||
// the existing collection grant ledger. It is written atomically with the
|
||||
// official GachaSubType=3 first-pick transaction.
|
||||
const FirstGachaCompletedIdentity = "account:first-gacha-completed"
|
||||
|
||||
type collectionSnapshot struct {
|
||||
Version string `json:"version"`
|
||||
NextCharacterIndex uint64 `json:"next_character_index"`
|
||||
NextCostumeIndex uint64 `json:"next_costume_index"`
|
||||
LatestPreview []uint64 `json:"latest_preview"`
|
||||
PreviewEventIndex uint64 `json:"preview_event_index"`
|
||||
PreviewLocked bool `json:"preview_locked"`
|
||||
Characters []Character `json:"characters,omitempty"`
|
||||
Costumes []Costume `json:"costumes,omitempty"`
|
||||
BaseCostumeLevels map[string]uint64 `json:"base_costume_levels"`
|
||||
CostumePotential map[string][]uint64 `json:"costume_potential,omitempty"`
|
||||
CharAwake map[string]CharAwakeProgress `json:"char_awake,omitempty"`
|
||||
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
|
||||
GachaSelectionChanges map[string]uint64 `json:"gacha_selection_changes,omitempty"`
|
||||
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
|
||||
GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
|
||||
GachaFixed map[string]GachaFixedState `json:"gacha_fixed,omitempty"`
|
||||
GachaApplied map[string]bool `json:"gacha_applied,omitempty"`
|
||||
GachaPointExchange map[string]GachaPointExchange `json:"gacha_point_exchanges,omitempty"`
|
||||
GachaCountCorrected bool `json:"gacha_count_corrected"`
|
||||
Grants map[string]CollectionGrant `json:"grants,omitempty"`
|
||||
}
|
||||
|
||||
// CollectionStore owns non-stackable character/costume rewards as one atomic
|
||||
// save. It is shared by gacha, CharInfo, CostumeInfo and CharGrowth so a roll
|
||||
// cannot exist only in the result animation and disappear after relogging.
|
||||
type CollectionStore struct {
|
||||
mu sync.Mutex
|
||||
store stateio.AtomicEntryStore
|
||||
base []Costume
|
||||
baseCharacters []Character
|
||||
data collectionSnapshot
|
||||
persisted bool
|
||||
}
|
||||
|
||||
func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore, error) {
|
||||
if store == nil {
|
||||
return nil, errors.New("player: nil collection store")
|
||||
}
|
||||
entries, ok := store.(stateio.AtomicEntryStore)
|
||||
if !ok {
|
||||
return nil, errors.New("player: collection store requires atomic entry storage")
|
||||
}
|
||||
s := &CollectionStore{store: entries, base: append([]Costume(nil), base...), data: collectionSnapshot{
|
||||
Version: versionconfig.State(), NextCharacterIndex: 920000001, NextCostumeIndex: 930000001,
|
||||
BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]GachaSelection{}, GachaSelectionChanges: map[string]uint64{},
|
||||
CostumePotential: map[string][]uint64{},
|
||||
CharAwake: map[string]CharAwakeProgress{},
|
||||
StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
|
||||
GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{},
|
||||
}}
|
||||
b, err := store.Load("collection")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b == nil {
|
||||
if err := stateio.RequireNoEntries(entries, collectionDomain, collectionEntryBuckets[:]...); err != nil {
|
||||
return nil, fmt.Errorf("player: invalid collection storage: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
if err := rejectInlineCollectionEntries(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &s.data); err != nil {
|
||||
return nil, fmt.Errorf("player: decode collection: %w", err)
|
||||
}
|
||||
if err := loadCollectionEntries(entries, &s.data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.data.Version != versionconfig.State() || s.data.NextCharacterIndex < 920000001 || s.data.NextCostumeIndex < 930000001 {
|
||||
return nil, errors.New("player: invalid collection save")
|
||||
}
|
||||
if s.data.BaseCostumeLevels == nil {
|
||||
s.data.BaseCostumeLevels = map[string]uint64{}
|
||||
}
|
||||
if s.data.CostumePotential == nil {
|
||||
return nil, errors.New("player: collection save requires costume_potential; migrate the development save")
|
||||
}
|
||||
if s.data.CharAwake == nil {
|
||||
return nil, errors.New("player: collection save requires char_awake; migrate the development save")
|
||||
}
|
||||
for key, progress := range s.data.CharAwake {
|
||||
uniqueID, parseErr := strconv.ParseUint(key, 10, 64)
|
||||
if parseErr != nil || uniqueID == 0 || (progress.ImprintLevels == [3]uint64{} && !progress.IsAwake) {
|
||||
return nil, errors.New("player: invalid char_awake ledger")
|
||||
}
|
||||
}
|
||||
if s.data.GachaSelections == nil {
|
||||
s.data.GachaSelections = map[string][]GachaSelection{}
|
||||
}
|
||||
if s.data.GachaSelectionChanges == nil {
|
||||
s.data.GachaSelectionChanges = map[string]uint64{}
|
||||
}
|
||||
for key, count := range s.data.GachaSelectionChanges {
|
||||
groupID, parseErr := strconv.ParseUint(key, 10, 64)
|
||||
if parseErr != nil || groupID == 0 || key != strconv.FormatUint(groupID, 10) || count == 0 {
|
||||
return nil, errors.New("player: invalid gacha selection change ledger")
|
||||
}
|
||||
}
|
||||
if s.data.StepUpProgress == nil {
|
||||
s.data.StepUpProgress = map[string]uint64{}
|
||||
}
|
||||
if s.data.GachaUsers == nil {
|
||||
s.data.GachaUsers = map[string]GachaUserState{}
|
||||
}
|
||||
if s.data.GachaFixed == nil {
|
||||
s.data.GachaFixed = map[string]GachaFixedState{}
|
||||
}
|
||||
if s.data.GachaApplied == nil {
|
||||
s.data.GachaApplied = map[string]bool{}
|
||||
}
|
||||
if s.data.GachaPointExchange == nil {
|
||||
s.data.GachaPointExchange = map[string]GachaPointExchange{}
|
||||
}
|
||||
if marker, exists := s.data.Grants[FirstGachaCompletedIdentity]; exists && !emptyCollectionGrant(marker) {
|
||||
return nil, errors.New("player: invalid first-gacha completion marker")
|
||||
}
|
||||
if err := validateCharacters(s.data.Characters); err != nil && len(s.data.Characters) != 0 {
|
||||
return nil, err
|
||||
}
|
||||
s.persisted = true
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *CollectionStore) EnsurePersisted() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
b, err := s.store.Load("collection")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b != nil {
|
||||
return nil
|
||||
}
|
||||
return s.commit(cloneCollection(s.data))
|
||||
}
|
||||
|
||||
func emptyCollectionGrant(grant CollectionGrant) bool {
|
||||
return len(grant.CharacterIndices) == 0 && len(grant.CostumeIndices) == 0 &&
|
||||
len(grant.Upgrades) == 0 && len(grant.Exchanges) == 0 &&
|
||||
len(grant.ViewCostumeIDs) == 0 && grant.GachaGroupID == 0 &&
|
||||
grant.GachaPoint == 0 && len(grant.GachaFixed) == 0 &&
|
||||
len(grant.SelectionApplySortIDs) == 0
|
||||
}
|
||||
|
||||
// BindBaseCharacters attaches the authoritative base roster and rejects a
|
||||
// persisted collection that overlaps it. It never rewrites account state.
|
||||
func (s *CollectionStore) BindBaseCharacters(base []Character) error {
|
||||
if err := validateCharacters(base); err != nil && len(base) != 0 {
|
||||
return fmt.Errorf("player: invalid base characters: %w", err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
seen := make(map[uint64]uint64, len(base)+len(s.data.Characters))
|
||||
for _, character := range base {
|
||||
if prior := seen[character.ID]; prior != 0 {
|
||||
return fmt.Errorf("player: duplicate base character design %d", character.ID)
|
||||
}
|
||||
seen[character.ID] = character.InvenIndex
|
||||
}
|
||||
for _, character := range s.data.Characters {
|
||||
if prior := seen[character.ID]; prior != 0 {
|
||||
return fmt.Errorf("player: unrepaired duplicate character design %d (%d and %d)", character.ID, prior, character.InvenIndex)
|
||||
}
|
||||
seen[character.ID] = character.InvenIndex
|
||||
}
|
||||
s.baseCharacters = append([]Character(nil), base...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachRewardCostume joins an already-earned quest costume to the same
|
||||
// account-owned view used by gacha and starter costumes. The quest condition
|
||||
// must be checked by the caller; this does not grant an unearned costume.
|
||||
func (s *CollectionStore) AttachRewardCostume(reward Costume) error {
|
||||
if reward.InvenIndex == 0 || reward.ID == 0 || reward.UseChar == 0 {
|
||||
return errors.New("player: invalid earned quest costume")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, existing := range s.base {
|
||||
if existing.InvenIndex == reward.InvenIndex || existing.ID == reward.ID {
|
||||
return fmt.Errorf("player: duplicate earned quest costume %d", reward.InvenIndex)
|
||||
}
|
||||
}
|
||||
for _, existing := range s.data.Costumes {
|
||||
if existing.InvenIndex == reward.InvenIndex || existing.ID == reward.ID {
|
||||
return fmt.Errorf("player: earned quest costume overlaps collection %d", reward.InvenIndex)
|
||||
}
|
||||
}
|
||||
s.base = append(s.base, reward)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CollectionStore) StepUpProgress(groupID uint64) uint64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.StepUpProgress[strconv.FormatUint(groupID, 10)]
|
||||
}
|
||||
|
||||
// CompleteStepUp atomically advances one group. Repeating the same completed
|
||||
// step is allowed so a lost HTTP response can be retried without reopening it.
|
||||
func (s *CollectionStore) CompleteStepUp(groupID, step uint64) error {
|
||||
if groupID == 0 || step == 0 {
|
||||
return errors.New("player: invalid step-up completion")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := strconv.FormatUint(groupID, 10)
|
||||
completed := s.data.StepUpProgress[key]
|
||||
if completed >= step {
|
||||
return nil
|
||||
}
|
||||
if completed+1 != step {
|
||||
return fmt.Errorf("player: step-up group %d expects step %d, got %d", groupID, completed+1, step)
|
||||
}
|
||||
next := cloneCollection(s.data)
|
||||
next.StepUpProgress[key] = step
|
||||
return s.commit(next)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) SaveGachaSelections(groupID uint64, selections []GachaSelection, changeLimit uint64) error {
|
||||
if groupID == 0 || len(selections) == 0 {
|
||||
return errors.New("player: invalid gacha selection")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := strconv.FormatUint(groupID, 10)
|
||||
normalized := append([]GachaSelection(nil), selections...)
|
||||
sort.Slice(normalized, func(i, j int) bool { return normalized[i].Slot < normalized[j].Slot })
|
||||
if equalGachaSelections(s.data.GachaSelections[key], normalized) {
|
||||
return nil
|
||||
}
|
||||
if changeLimit != 0 && s.data.GachaSelectionChanges[key] >= changeLimit {
|
||||
return fmt.Errorf("player: gacha selection group %d exhausted %d changes", groupID, changeLimit)
|
||||
}
|
||||
next := cloneCollection(s.data)
|
||||
next.GachaSelections[key] = normalized
|
||||
if changeLimit != 0 {
|
||||
next.GachaSelectionChanges[key]++
|
||||
}
|
||||
return s.commit(next)
|
||||
}
|
||||
|
||||
func equalGachaSelections(a, b []GachaSelection) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *CollectionStore) GachaSelections(groupID uint64) []GachaSelection {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]GachaSelection(nil), s.data.GachaSelections[strconv.FormatUint(groupID, 10)]...)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) AllGachaSelections() []GachaSelection {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var result []GachaSelection
|
||||
for _, selections := range s.data.GachaSelections {
|
||||
result = append(result, selections...)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].GroupID != result[j].GroupID {
|
||||
return result[i].GroupID < result[j].GroupID
|
||||
}
|
||||
return result[i].Slot < result[j].Slot
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *CollectionStore) GachaSelectionChangeCounts() []GachaSelectionChangeCount {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
result := make([]GachaSelectionChangeCount, 0, len(s.data.GachaSelectionChanges))
|
||||
for key, count := range s.data.GachaSelectionChanges {
|
||||
groupID, err := strconv.ParseUint(key, 10, 64)
|
||||
if err == nil && groupID != 0 && count != 0 {
|
||||
result = append(result, GachaSelectionChangeCount{GroupID: groupID, Count: count})
|
||||
}
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].GroupID < result[j].GroupID })
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *CollectionStore) SetPreview(eventIndex uint64, costumeIDs []uint64) error {
|
||||
if eventIndex == 0 || len(costumeIDs) != 10 {
|
||||
return errors.New("player: infinite preview requires an event index and ten costumes")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
next := cloneCollection(s.data)
|
||||
next.LatestPreview = append([]uint64(nil), costumeIDs...)
|
||||
next.PreviewEventIndex = eventIndex
|
||||
next.PreviewLocked = false
|
||||
return s.commit(next)
|
||||
}
|
||||
|
||||
// LockPreview records the official Resemara confirmation boundary. The event
|
||||
// index is created and persisted with the preview; locking only changes the
|
||||
// confirmation bit, so an unlocked preview remains representable in GachaInfo.
|
||||
func (s *CollectionStore) LockPreview(eventIndex uint64) error {
|
||||
if eventIndex == 0 {
|
||||
return errors.New("player: invalid infinite preview event index")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.data.LatestPreview) != 10 || s.data.PreviewEventIndex == 0 {
|
||||
return errors.New("player: no complete infinite gacha preview to lock")
|
||||
}
|
||||
if eventIndex != s.data.PreviewEventIndex {
|
||||
return fmt.Errorf("player: stale infinite preview event index %d", eventIndex)
|
||||
}
|
||||
next := cloneCollection(s.data)
|
||||
next.PreviewLocked = true
|
||||
return s.commit(next)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) PreviewLock() (uint64, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.PreviewEventIndex, s.data.PreviewLocked
|
||||
}
|
||||
|
||||
func (s *CollectionStore) LatestPreview() []uint64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]uint64(nil), s.data.LatestPreview...)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) ConfirmInfinite(identity string, design *gamedata.InfiniteGachaDesign) (CollectionGrant, error) {
|
||||
if identity == "" || design == nil {
|
||||
return CollectionGrant{}, errors.New("player: invalid collection grant")
|
||||
}
|
||||
s.mu.Lock()
|
||||
preview := append([]uint64(nil), s.data.LatestPreview...)
|
||||
locked := s.data.PreviewLocked && s.data.PreviewEventIndex != 0
|
||||
s.mu.Unlock()
|
||||
if len(preview) != design.Count {
|
||||
return CollectionGrant{}, errors.New("player: no complete infinite gacha preview to confirm")
|
||||
}
|
||||
if !locked {
|
||||
return CollectionGrant{}, errors.New("player: infinite gacha preview is not locked")
|
||||
}
|
||||
return s.grantCostumes(identity, preview, design.Character, nil)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) GrantRegular(identity string, costumeIDs []uint64, design *gamedata.RegularGachaCatalog) (CollectionGrant, error) {
|
||||
if identity == "" || len(costumeIDs) == 0 || design == nil {
|
||||
return CollectionGrant{}, errors.New("player: invalid regular gacha grant")
|
||||
}
|
||||
return s.grantCostumes(identity, costumeIDs, design.Character, nil)
|
||||
}
|
||||
|
||||
// RecordGrantMarker persists an account-level completion/purchase fact that
|
||||
// has no character or costume payload. It is intentionally separate from an
|
||||
// inventory reward marker so purchase-count protocol state remains correct
|
||||
// even after the granted consumable has been used.
|
||||
func (s *CollectionStore) RecordGrantMarker(identity string) error {
|
||||
if identity == "" {
|
||||
return errors.New("player: invalid collection marker")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.data.Grants[identity]; exists {
|
||||
return nil
|
||||
}
|
||||
next := cloneCollection(s.data)
|
||||
next.Grants[identity] = CollectionGrant{}
|
||||
return s.commit(next)
|
||||
}
|
||||
|
||||
// GrantGachaPointCostume debits one pickup exchange cost and grants the
|
||||
// selected costume in the same collection commit. The grant identity makes a
|
||||
// transport retry return the original result without spending points or
|
||||
// incrementing ExchangeItemCount twice.
|
||||
func (s *CollectionStore) GrantGachaPointCostume(identity string, group gamedata.GachaGroupDesign, costumeID uint64, design *gamedata.RegularGachaCatalog) (CollectionGrant, error) {
|
||||
if identity == "" || group.ID == 0 || group.PickUpExchangeCost == 0 || costumeID == 0 || design == nil {
|
||||
return CollectionGrant{}, errors.New("player: invalid gacha point costume exchange")
|
||||
}
|
||||
return s.grantCostumes(identity, []uint64{costumeID}, design.Character, func(next *collectionSnapshot, _ *CollectionGrant) error {
|
||||
key := strconv.FormatUint(group.ID, 10)
|
||||
user, ok := next.GachaUsers[key]
|
||||
if !ok || user.Point < group.PickUpExchangeCost {
|
||||
return errors.New("player: insufficient gacha point")
|
||||
}
|
||||
if user.ExchangeItemCount == ^uint64(0) {
|
||||
return errors.New("player: gacha item exchange count overflow")
|
||||
}
|
||||
user.GroupID = group.ID
|
||||
user.Point -= group.PickUpExchangeCost
|
||||
user.ExchangeItemCount++
|
||||
next.GachaUsers[key] = user
|
||||
next.GachaPointExchange[identity] = GachaPointExchange{GroupID: group.ID, Count: group.PickUpExchangeCost}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *CollectionStore) GrantRegularPurchase(identity string, costumeIDs []uint64, design *gamedata.RegularGachaCatalog, purchase GachaPurchase) (CollectionGrant, error) {
|
||||
if identity == "" || len(costumeIDs) == 0 || design == nil || purchase.Group.ID == 0 {
|
||||
return CollectionGrant{}, errors.New("player: invalid regular gacha purchase")
|
||||
}
|
||||
return s.grantCostumes(identity, costumeIDs, design.Character, func(next *collectionSnapshot, grant *CollectionGrant) error {
|
||||
if err := applyStepUpProgress(next, purchase.StepUpGroupID, purchase.StepUpStep); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyGachaPurchase(next, identity, costumeIDs, purchase, grant); err != nil {
|
||||
return err
|
||||
}
|
||||
if purchase.CompletionGrant != "" {
|
||||
if _, exists := next.Grants[purchase.CompletionGrant]; exists {
|
||||
return fmt.Errorf("player: gacha completion grant %q already exists", purchase.CompletionGrant)
|
||||
}
|
||||
next.Grants[purchase.CompletionGrant] = CollectionGrant{}
|
||||
}
|
||||
if purchase.Group.GachaSubType == 3 {
|
||||
next.Grants[FirstGachaCompletedIdentity] = CollectionGrant{}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GrantEquipmentPurchase records only the durable gacha accounting. Equipment
|
||||
// instances belong to EquipmentInventory (they have a different wire type),
|
||||
// while points and fixed counters share GachaUserDBInfo/GachaFixedDBInfo with
|
||||
// costume draws. The stored empty grant is the idempotency marker.
|
||||
func (s *CollectionStore) GrantEquipmentPurchase(identity string, count uint64, purchase GachaPurchase) (CollectionGrant, error) {
|
||||
if identity == "" || count == 0 || purchase.Group.ID == 0 {
|
||||
return CollectionGrant{}, errors.New("player: invalid equipment gacha purchase")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if grant, ok := s.data.Grants[identity]; ok {
|
||||
return cloneGrant(grant), nil
|
||||
}
|
||||
next := cloneCollection(s.data)
|
||||
grant := CollectionGrant{}
|
||||
if err := applyGachaPurchase(&next, identity, make([]uint64, count), purchase, &grant); err != nil {
|
||||
return CollectionGrant{}, err
|
||||
}
|
||||
next.Grants[identity] = cloneGrant(grant)
|
||||
if err := s.commit(next); err != nil {
|
||||
return CollectionGrant{}, err
|
||||
}
|
||||
return grant, nil
|
||||
}
|
||||
|
||||
// GrantEquipmentDraw records a standalone ticket draw without inventing a
|
||||
// schedule group, points, purchase counts, or fixed-pity state.
|
||||
func (s *CollectionStore) GrantEquipmentDraw(identity string) (CollectionGrant, error) {
|
||||
if identity == "" {
|
||||
return CollectionGrant{}, errors.New("player: invalid equipment draw identity")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if grant, ok := s.data.Grants[identity]; ok {
|
||||
return cloneGrant(grant), nil
|
||||
}
|
||||
next := cloneCollection(s.data)
|
||||
grant := CollectionGrant{}
|
||||
next.Grants[identity] = grant
|
||||
if err := s.commit(next); err != nil {
|
||||
return CollectionGrant{}, err
|
||||
}
|
||||
return grant, nil
|
||||
}
|
||||
|
||||
func (s *CollectionStore) grantCostumes(identity string, costumeIDs []uint64, character func(uint64) (gamedata.CharacterDesign, bool), mutate func(*collectionSnapshot, *CollectionGrant) error) (CollectionGrant, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if grant, ok := s.data.Grants[identity]; ok {
|
||||
return cloneGrant(grant), nil
|
||||
}
|
||||
next := cloneCollection(s.data)
|
||||
grant := CollectionGrant{ViewCostumeIDs: append([]uint64(nil), costumeIDs...)}
|
||||
now := uint64(time.Now().UnixMilli())
|
||||
for sortIndex, costumeID := range costumeIDs {
|
||||
if costumeID == 0 {
|
||||
return CollectionGrant{}, errors.New("player: preview has invalid costume")
|
||||
}
|
||||
characterDesign, found := character(costumeID)
|
||||
if !found {
|
||||
return CollectionGrant{}, fmt.Errorf("player: costume %d has no character design", costumeID)
|
||||
}
|
||||
maxLevel := characterDesign.CostumeMaxLevel
|
||||
if maxLevel == 0 {
|
||||
return CollectionGrant{}, fmt.Errorf("player: costume %d has zero max level", costumeID)
|
||||
}
|
||||
if position, found := findCostume(next.Costumes, costumeID); found {
|
||||
before := next.Costumes[position].Level
|
||||
if before < maxLevel {
|
||||
next.Costumes[position].Level++
|
||||
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: next.Costumes[position].InvenIndex, CostumeID: costumeID, Before: before, After: next.Costumes[position].Level, SortID: uint64(sortIndex)})
|
||||
} else {
|
||||
exchange, err := costumeOverflowExchange(costumeID, next.Costumes[position].InvenIndex, uint64(sortIndex), characterDesign)
|
||||
if err != nil {
|
||||
return CollectionGrant{}, err
|
||||
}
|
||||
grant.Exchanges = append(grant.Exchanges, exchange)
|
||||
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: exchange.InvenIndex, CostumeID: costumeID, Before: maxLevel, After: maxLevel, SortID: exchange.SortID})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if base, found := findCostume(s.base, costumeID); found {
|
||||
key := strconv.FormatUint(s.base[base].InvenIndex, 10)
|
||||
before := next.BaseCostumeLevels[key]
|
||||
if before < s.base[base].Level {
|
||||
before = s.base[base].Level
|
||||
}
|
||||
if before < maxLevel {
|
||||
next.BaseCostumeLevels[key] = before + 1
|
||||
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: s.base[base].InvenIndex, CostumeID: costumeID, Before: before, After: before + 1, SortID: uint64(sortIndex)})
|
||||
} else {
|
||||
exchange, err := costumeOverflowExchange(costumeID, s.base[base].InvenIndex, uint64(sortIndex), characterDesign)
|
||||
if err != nil {
|
||||
return CollectionGrant{}, err
|
||||
}
|
||||
grant.Exchanges = append(grant.Exchanges, exchange)
|
||||
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: exchange.InvenIndex, CostumeID: costumeID, Before: maxLevel, After: maxLevel, SortID: exchange.SortID})
|
||||
}
|
||||
continue
|
||||
}
|
||||
characterIndex, characterExists := findCharacterByID(s.baseCharacters, next.Characters, characterDesign.ID)
|
||||
costumeIndex := next.NextCostumeIndex
|
||||
next.NextCostumeIndex++
|
||||
if !characterExists {
|
||||
characterIndex = next.NextCharacterIndex
|
||||
next.NextCharacterIndex++
|
||||
ownedCharacter := Character{InvenIndex: characterIndex, ID: characterDesign.ID, HP: characterDesign.HP, Level: 1,
|
||||
CostumeID: costumeID, UseCostume: costumeIndex, TalentLevel: 1, SolidarityReward: 1,
|
||||
ExpiryTime: ^uint64(0) - 32399999, ConnectPotentialCostume: costumeID}
|
||||
next.Characters = append(next.Characters, ownedCharacter)
|
||||
grant.CharacterIndices = append(grant.CharacterIndices, characterIndex)
|
||||
}
|
||||
costume := Costume{InvenIndex: costumeIndex, ID: costumeID, UseChar: characterIndex, SortID: uint64(sortIndex), TimeValue: now}
|
||||
next.Costumes = append(next.Costumes, costume)
|
||||
grant.CostumeIndices = append(grant.CostumeIndices, costumeIndex)
|
||||
}
|
||||
if mutate != nil {
|
||||
if err := mutate(&next, &grant); err != nil {
|
||||
return CollectionGrant{}, err
|
||||
}
|
||||
}
|
||||
next.Grants[identity] = cloneGrant(grant)
|
||||
if err := s.commit(next); err != nil {
|
||||
return CollectionGrant{}, err
|
||||
}
|
||||
return grant, nil
|
||||
}
|
||||
|
||||
func costumeOverflowExchange(costumeID, invenIndex, sortID uint64, design gamedata.CharacterDesign) (CostumeExchange, error) {
|
||||
if design.OverflowItemType == 0 || design.OverflowItemCount == 0 {
|
||||
return CostumeExchange{}, fmt.Errorf("player: costume %d has no overflow exchange design", costumeID)
|
||||
}
|
||||
return CostumeExchange{
|
||||
InvenIndex: invenIndex,
|
||||
OriginalItemType: 11, OriginalItemID: costumeID, OriginalCount: 1,
|
||||
ExchangeItemType: design.OverflowItemType, ExchangeItemID: design.OverflowItemID,
|
||||
ExchangeCount: design.OverflowItemCount, SortID: sortID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *CollectionStore) Characters() []Character {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]Character(nil), s.data.Characters...)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) FindCharacter(index uint64) (Character, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, c := range s.data.Characters {
|
||||
if c.InvenIndex == index {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return Character{}, false
|
||||
}
|
||||
|
||||
// CanUpdateCharacter validates a growth mutation before any currency or
|
||||
// inventory is charged. Promotion changes character.ID but not InvenIndex.
|
||||
func (s *CollectionStore) CanUpdateCharacter(oldID uint64, character Character) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.validateCharacterUpdate(oldID, character)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) validateCharacterUpdate(oldID uint64, character Character) error {
|
||||
if oldID == 0 || character.ID == 0 || character.InvenIndex == 0 {
|
||||
return errors.New("player: invalid collection character update")
|
||||
}
|
||||
found := false
|
||||
for _, existing := range s.data.Characters {
|
||||
if existing.InvenIndex == character.InvenIndex {
|
||||
if existing.ID != oldID {
|
||||
return fmt.Errorf("player: collection character %d changed before growth", character.InvenIndex)
|
||||
}
|
||||
found = true
|
||||
} else if existing.ID == character.ID {
|
||||
return fmt.Errorf("player: duplicate collection character design %d", character.ID)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("player: collection character %d not found", character.InvenIndex)
|
||||
}
|
||||
for _, existing := range s.baseCharacters {
|
||||
if existing.ID == character.ID {
|
||||
return fmt.Errorf("player: duplicate base character design %d", character.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CollectionStore) UpdateCharacter(oldID uint64, character Character) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.validateCharacterUpdate(oldID, character); err != nil {
|
||||
return err
|
||||
}
|
||||
next := cloneCollection(s.data)
|
||||
for i := range next.Characters {
|
||||
if next.Characters[i].InvenIndex == character.InvenIndex {
|
||||
next.Characters[i] = character
|
||||
return s.commit(next)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("player: collection character %d not found", character.InvenIndex)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) Costumes() []Costume {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
result := append([]Costume(nil), s.base...)
|
||||
for i := range result {
|
||||
if level := s.data.BaseCostumeLevels[strconv.FormatUint(result[i].InvenIndex, 10)]; level > result[i].Level {
|
||||
result[i].Level = level
|
||||
}
|
||||
result[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[strconv.FormatUint(result[i].InvenIndex, 10)]...)
|
||||
}
|
||||
collection := append([]Costume(nil), s.data.Costumes...)
|
||||
for i := range collection {
|
||||
collection[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[strconv.FormatUint(collection[i].InvenIndex, 10)]...)
|
||||
}
|
||||
return append(result, collection...)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) ValidateCostumePotentialActivation(costumeIndex uint64, nodes []uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.validateCostumePotentialActivation(costumeIndex, nodes)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) validateCostumePotentialActivation(costumeIndex uint64, nodes []uint64) error {
|
||||
if costumeIndex == 0 || len(nodes) == 0 {
|
||||
return errors.New("player: invalid costume potential activation")
|
||||
}
|
||||
found := false
|
||||
for _, costume := range s.base {
|
||||
found = found || costume.InvenIndex == costumeIndex
|
||||
}
|
||||
for _, costume := range s.data.Costumes {
|
||||
found = found || costume.InvenIndex == costumeIndex
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("player: costume %d not found", costumeIndex)
|
||||
}
|
||||
key := strconv.FormatUint(costumeIndex, 10)
|
||||
active := make(map[uint64]bool, len(s.data.CostumePotential[key])+len(nodes))
|
||||
for _, id := range s.data.CostumePotential[key] {
|
||||
if id == 0 || active[id] {
|
||||
return fmt.Errorf("player: invalid saved potential node for costume %d", costumeIndex)
|
||||
}
|
||||
active[id] = true
|
||||
}
|
||||
for _, id := range nodes {
|
||||
if id == 0 || active[id] {
|
||||
return fmt.Errorf("player: duplicate potential node %d for costume %d", id, costumeIndex)
|
||||
}
|
||||
active[id] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CollectionStore) ActivateCostumePotential(costumeIndex uint64, nodes []uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.validateCostumePotentialActivation(costumeIndex, nodes); err != nil {
|
||||
return err
|
||||
}
|
||||
next := cloneCollection(s.data)
|
||||
key := strconv.FormatUint(costumeIndex, 10)
|
||||
next.CostumePotential[key] = append(next.CostumePotential[key], nodes...)
|
||||
sort.Slice(next.CostumePotential[key], func(i, j int) bool { return next.CostumePotential[key][i] < next.CostumePotential[key][j] })
|
||||
return s.commit(next)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) CharAwakeState(uniqueCharID uint64) (CharAwakeProgress, bool) {
|
||||
if uniqueCharID == 0 {
|
||||
return CharAwakeProgress{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
progress, ok := s.data.CharAwake[strconv.FormatUint(uniqueCharID, 10)]
|
||||
return progress, ok
|
||||
}
|
||||
|
||||
func (s *CollectionStore) CharAwakeStates() map[uint64]CharAwakeProgress {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
result := make(map[uint64]CharAwakeProgress, len(s.data.CharAwake))
|
||||
for key, progress := range s.data.CharAwake {
|
||||
uniqueID, err := strconv.ParseUint(key, 10, 64)
|
||||
if err == nil && uniqueID != 0 {
|
||||
result[uniqueID] = progress
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// UpdateCharAwake atomically advances one UniqueCharId ledger entry. expected
|
||||
// rejects stale concurrent requests before one response can overwrite another.
|
||||
func (s *CollectionStore) UpdateCharAwake(uniqueCharID uint64, expected, nextProgress CharAwakeProgress) error {
|
||||
if uniqueCharID == 0 || nextProgress == (CharAwakeProgress{}) {
|
||||
return errors.New("player: invalid character awakening progress")
|
||||
}
|
||||
for i := range nextProgress.ImprintLevels {
|
||||
if nextProgress.ImprintLevels[i] < expected.ImprintLevels[i] {
|
||||
return errors.New("player: character imprint level cannot decrease")
|
||||
}
|
||||
}
|
||||
if expected.IsAwake && !nextProgress.IsAwake {
|
||||
return errors.New("player: character awakening cannot be removed")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := strconv.FormatUint(uniqueCharID, 10)
|
||||
current := s.data.CharAwake[key]
|
||||
if current != expected {
|
||||
return errors.New("player: stale character awakening progress")
|
||||
}
|
||||
next := cloneCollection(s.data)
|
||||
next.CharAwake[key] = nextProgress
|
||||
return s.commit(next)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) Grant(identity string) (CollectionGrant, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
grant, ok := s.data.Grants[identity]
|
||||
return cloneGrant(grant), ok
|
||||
}
|
||||
|
||||
func (s *CollectionStore) FirstGachaCompleted() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, completed := s.data.Grants[FirstGachaCompletedIdentity]
|
||||
return completed
|
||||
}
|
||||
|
||||
func (s *CollectionStore) CharacterByIndex(index uint64) (Character, bool) {
|
||||
for _, character := range s.Characters() {
|
||||
if character.InvenIndex == index {
|
||||
return character, true
|
||||
}
|
||||
}
|
||||
return Character{}, false
|
||||
}
|
||||
|
||||
func (s *CollectionStore) CostumeByIndex(index uint64) (Costume, bool) {
|
||||
for _, costume := range s.Costumes() {
|
||||
if costume.InvenIndex == index {
|
||||
return costume, true
|
||||
}
|
||||
}
|
||||
return Costume{}, false
|
||||
}
|
||||
|
||||
func findCostume(costumes []Costume, id uint64) (int, bool) {
|
||||
for i, costume := range costumes {
|
||||
if costume.ID == id {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return -1, false
|
||||
}
|
||||
|
||||
func findCharacterByID(base, collection []Character, id uint64) (uint64, bool) {
|
||||
for _, character := range base {
|
||||
if character.ID == id {
|
||||
return character.InvenIndex, true
|
||||
}
|
||||
}
|
||||
for _, character := range collection {
|
||||
if character.ID == id {
|
||||
return character.InvenIndex, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func cloneGrant(grant CollectionGrant) CollectionGrant {
|
||||
grant.CharacterIndices = append([]uint64(nil), grant.CharacterIndices...)
|
||||
grant.CostumeIndices = append([]uint64(nil), grant.CostumeIndices...)
|
||||
grant.Upgrades = append([]CostumeUpgrade(nil), grant.Upgrades...)
|
||||
grant.Exchanges = append([]CostumeExchange(nil), grant.Exchanges...)
|
||||
grant.ViewCostumeIDs = append([]uint64(nil), grant.ViewCostumeIDs...)
|
||||
grant.GachaFixed = append([]GachaFixedState(nil), grant.GachaFixed...)
|
||||
grant.SelectionApplySortIDs = append([]uint64(nil), grant.SelectionApplySortIDs...)
|
||||
return grant
|
||||
}
|
||||
|
||||
func cloneCollection(in collectionSnapshot) collectionSnapshot {
|
||||
out := in
|
||||
out.LatestPreview = append([]uint64(nil), in.LatestPreview...)
|
||||
out.Characters = append([]Character(nil), in.Characters...)
|
||||
out.Costumes = append([]Costume(nil), in.Costumes...)
|
||||
out.BaseCostumeLevels = make(map[string]uint64, len(in.BaseCostumeLevels))
|
||||
for k, v := range in.BaseCostumeLevels {
|
||||
out.BaseCostumeLevels[k] = v
|
||||
}
|
||||
out.CostumePotential = make(map[string][]uint64, len(in.CostumePotential))
|
||||
for k, v := range in.CostumePotential {
|
||||
out.CostumePotential[k] = append([]uint64(nil), v...)
|
||||
}
|
||||
out.CharAwake = make(map[string]CharAwakeProgress, len(in.CharAwake))
|
||||
for k, v := range in.CharAwake {
|
||||
out.CharAwake[k] = v
|
||||
}
|
||||
out.GachaSelections = make(map[string][]GachaSelection, len(in.GachaSelections))
|
||||
for k, v := range in.GachaSelections {
|
||||
out.GachaSelections[k] = append([]GachaSelection(nil), v...)
|
||||
}
|
||||
out.GachaSelectionChanges = make(map[string]uint64, len(in.GachaSelectionChanges))
|
||||
for k, v := range in.GachaSelectionChanges {
|
||||
out.GachaSelectionChanges[k] = v
|
||||
}
|
||||
out.StepUpProgress = make(map[string]uint64, len(in.StepUpProgress))
|
||||
for k, v := range in.StepUpProgress {
|
||||
out.StepUpProgress[k] = v
|
||||
}
|
||||
out.GachaUsers = make(map[string]GachaUserState, len(in.GachaUsers))
|
||||
for k, v := range in.GachaUsers {
|
||||
out.GachaUsers[k] = v
|
||||
}
|
||||
out.GachaFixed = make(map[string]GachaFixedState, len(in.GachaFixed))
|
||||
for k, v := range in.GachaFixed {
|
||||
out.GachaFixed[k] = v
|
||||
}
|
||||
out.GachaApplied = make(map[string]bool, len(in.GachaApplied))
|
||||
for k, v := range in.GachaApplied {
|
||||
out.GachaApplied[k] = v
|
||||
}
|
||||
out.GachaPointExchange = make(map[string]GachaPointExchange, len(in.GachaPointExchange))
|
||||
for k, v := range in.GachaPointExchange {
|
||||
out.GachaPointExchange[k] = v
|
||||
}
|
||||
out.Grants = make(map[string]CollectionGrant, len(in.Grants))
|
||||
for k, v := range in.Grants {
|
||||
out.Grants[k] = cloneGrant(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *CollectionStore) commit(next collectionSnapshot) error {
|
||||
changes, err := diffCollectionEntries(s.data, next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var core []byte
|
||||
if !s.persisted || !sameCollectionCore(s.data, next) {
|
||||
core, err = json.Marshal(collectionCore(next))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if core == nil && len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := s.store.SaveWithEntries("collection", core, changes); err != nil {
|
||||
return fmt.Errorf("player: persist collection: %w", err)
|
||||
}
|
||||
s.data = next
|
||||
s.persisted = true
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
)
|
||||
|
||||
const collectionDomain = "collection"
|
||||
|
||||
var collectionEntryBuckets = [...]string{
|
||||
"characters", "costumes", "grants", "gacha_applied", "gacha_users",
|
||||
"gacha_fixed", "step_up_progress", "gacha_point_exchanges",
|
||||
"gacha_selections", "gacha_selection_changes", "costume_potential", "char_awake",
|
||||
}
|
||||
|
||||
func rejectInlineCollectionEntries(raw []byte) error {
|
||||
if err := stateio.RequireExactJSONObject(raw, "version", "next_character_index", "next_costume_index", "latest_preview", "preview_event_index", "preview_locked", "base_costume_levels", "gacha_count_corrected"); err != nil {
|
||||
return fmt.Errorf("player: incompatible collection core: %w", err)
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return fmt.Errorf("player: decode collection core: %w", err)
|
||||
}
|
||||
for _, bucket := range collectionEntryBuckets {
|
||||
if _, found := fields[bucket]; found {
|
||||
return fmt.Errorf("player: collection core contains entry bucket %q", bucket)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadEntryMap[T any](store stateio.EntryStore, bucket string) (map[string]T, error) {
|
||||
entries, err := store.ListEntries(collectionDomain, bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("player: load collection %s: %w", bucket, err)
|
||||
}
|
||||
result := make(map[string]T, len(entries))
|
||||
for key, payload := range entries {
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("player: empty collection %s key", bucket)
|
||||
}
|
||||
var value T
|
||||
if err := json.Unmarshal(payload, &value); err != nil {
|
||||
return nil, fmt.Errorf("player: decode collection %s entry %q: %w", bucket, key, err)
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadCollectionEntries(store stateio.EntryStore, data *collectionSnapshot) error {
|
||||
var err error
|
||||
if data.Characters, err = loadIndexedEntries[Character](store, "characters", func(c Character) uint64 { return c.InvenIndex }); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.Costumes, err = loadIndexedEntries[Costume](store, "costumes", func(c Costume) uint64 { return c.InvenIndex }); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.Grants, err = loadEntryMap[CollectionGrant](store, "grants"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.GachaApplied, err = loadEntryMap[bool](store, "gacha_applied"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.GachaUsers, err = loadEntryMap[GachaUserState](store, "gacha_users"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.GachaFixed, err = loadEntryMap[GachaFixedState](store, "gacha_fixed"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.StepUpProgress, err = loadEntryMap[uint64](store, "step_up_progress"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.GachaPointExchange, err = loadEntryMap[GachaPointExchange](store, "gacha_point_exchanges"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.GachaSelections, err = loadEntryMap[[]GachaSelection](store, "gacha_selections"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.GachaSelectionChanges, err = loadEntryMap[uint64](store, "gacha_selection_changes"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.CostumePotential, err = loadEntryMap[[]uint64](store, "costume_potential"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.CharAwake, err = loadEntryMap[CharAwakeProgress](store, "char_awake"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadIndexedEntries[T any](store stateio.EntryStore, bucket string, index func(T) uint64) ([]T, error) {
|
||||
values, err := loadEntryMap[T](store, bucket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
indices := make([]uint64, 0, len(values))
|
||||
for key, value := range values {
|
||||
parsed, parseErr := strconv.ParseUint(key, 10, 64)
|
||||
if parseErr != nil || parsed == 0 || key != strconv.FormatUint(parsed, 10) || index(value) != parsed {
|
||||
return nil, fmt.Errorf("player: invalid collection %s entry key %q", bucket, key)
|
||||
}
|
||||
indices = append(indices, parsed)
|
||||
}
|
||||
sort.Slice(indices, func(i, j int) bool { return indices[i] < indices[j] })
|
||||
result := make([]T, 0, len(indices))
|
||||
for _, id := range indices {
|
||||
result = append(result, values[strconv.FormatUint(id, 10)])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func collectionCore(in collectionSnapshot) collectionSnapshot {
|
||||
in.Characters = nil
|
||||
in.Costumes = nil
|
||||
in.Grants = nil
|
||||
in.GachaApplied = nil
|
||||
in.GachaUsers = nil
|
||||
in.GachaFixed = nil
|
||||
in.StepUpProgress = nil
|
||||
in.GachaPointExchange = nil
|
||||
in.GachaSelections = nil
|
||||
in.GachaSelectionChanges = nil
|
||||
in.CostumePotential = nil
|
||||
in.CharAwake = nil
|
||||
return in
|
||||
}
|
||||
|
||||
func sameCollectionCore(a, b collectionSnapshot) bool {
|
||||
return reflect.DeepEqual(collectionCore(a), collectionCore(b))
|
||||
}
|
||||
|
||||
func diffEntryMap[T any](bucket string, before, after map[string]T, changes *[]stateio.EntryMutation) error {
|
||||
for key, value := range after {
|
||||
old, found := before[key]
|
||||
if found && reflect.DeepEqual(old, value) {
|
||||
continue
|
||||
}
|
||||
if key == "" {
|
||||
return fmt.Errorf("player: empty collection %s key", bucket)
|
||||
}
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("player: encode collection %s entry %q: %w", bucket, key, err)
|
||||
}
|
||||
*changes = append(*changes, stateio.EntryMutation{Bucket: bucket, Key: key, Payload: payload})
|
||||
}
|
||||
for key := range before {
|
||||
if _, found := after[key]; !found {
|
||||
*changes = append(*changes, stateio.EntryMutation{Bucket: bucket, Key: key, Delete: true})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func indexedMap[T any](bucket string, values []T, index func(T) uint64) (map[string]T, error) {
|
||||
result := make(map[string]T, len(values))
|
||||
for _, value := range values {
|
||||
id := index(value)
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("player: zero collection %s index", bucket)
|
||||
}
|
||||
key := strconv.FormatUint(id, 10)
|
||||
if _, found := result[key]; found {
|
||||
return nil, fmt.Errorf("player: duplicate collection %s index %d", bucket, id)
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func diffIndexedEntries[T any](bucket string, before, after []T, index func(T) uint64, changes *[]stateio.EntryMutation) error {
|
||||
old, err := indexedMap(bucket, before, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := indexedMap(bucket, after, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return diffEntryMap(bucket, old, next, changes)
|
||||
}
|
||||
|
||||
func diffCollectionEntries(before, after collectionSnapshot) ([]stateio.EntryMutation, error) {
|
||||
changes := make([]stateio.EntryMutation, 0)
|
||||
for _, item := range []struct {
|
||||
bucket string
|
||||
old, next map[string]bool
|
||||
}{{"gacha_applied", before.GachaApplied, after.GachaApplied}} {
|
||||
if err := diffEntryMap(item.bucket, item.old, item.next, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := diffIndexedEntries("characters", before.Characters, after.Characters, func(c Character) uint64 { return c.InvenIndex }, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffIndexedEntries("costumes", before.Costumes, after.Costumes, func(c Costume) uint64 { return c.InvenIndex }, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("grants", before.Grants, after.Grants, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("gacha_users", before.GachaUsers, after.GachaUsers, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("gacha_fixed", before.GachaFixed, after.GachaFixed, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("step_up_progress", before.StepUpProgress, after.StepUpProgress, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("gacha_point_exchanges", before.GachaPointExchange, after.GachaPointExchange, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("gacha_selections", before.GachaSelections, after.GachaSelections, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("gacha_selection_changes", before.GachaSelectionChanges, after.GachaSelectionChanges, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("costume_potential", before.CostumePotential, after.CostumePotential, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("char_awake", before.CharAwake, after.CharAwake, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(changes) > 1 {
|
||||
sort.Slice(changes, func(i, j int) bool {
|
||||
if changes[i].Bucket != changes[j].Bucket {
|
||||
return changes[i].Bucket < changes[j].Bucket
|
||||
}
|
||||
return changes[i].Key < changes[j].Key
|
||||
})
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
)
|
||||
|
||||
type collectionWriteSpy struct {
|
||||
*stateio.Memory
|
||||
coreWrites int
|
||||
mutations [][]stateio.EntryMutation
|
||||
}
|
||||
|
||||
func (s *collectionWriteSpy) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error {
|
||||
if core != nil {
|
||||
s.coreWrites++
|
||||
}
|
||||
s.mutations = append(s.mutations, append([]stateio.EntryMutation(nil), changes...))
|
||||
return s.Memory.SaveWithEntries(domain, core, changes)
|
||||
}
|
||||
|
||||
func TestCollectionPersistsOnlyChangedEntries(t *testing.T) {
|
||||
storage := &collectionWriteSpy{Memory: stateio.NewMemory()}
|
||||
collection, err := OpenCollectionStore(storage, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := collection.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := cloneCollection(collection.data)
|
||||
first.Characters = []Character{{InvenIndex: 920000001, ID: 6490, Level: 1}}
|
||||
first.Costumes = []Costume{{InvenIndex: 930000001, ID: 64901, UseChar: 920000001}}
|
||||
first.Grants["first"] = CollectionGrant{CharacterIndices: []uint64{920000001}, CostumeIndices: []uint64{930000001}}
|
||||
first.GachaApplied["draw:first"] = true
|
||||
if err := collection.commit(first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core, err := storage.Load("collection")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, bucket := range collectionEntryBuckets {
|
||||
if bytes.Contains(core, []byte(`"`+bucket+`"`)) {
|
||||
t.Fatalf("collection core still contains %s", bucket)
|
||||
}
|
||||
}
|
||||
if storage.coreWrites != 1 || len(storage.mutations[1]) != 4 {
|
||||
t.Fatalf("initial entry save: core writes=%d mutations=%+v", storage.coreWrites, storage.mutations)
|
||||
}
|
||||
|
||||
second := cloneCollection(collection.data)
|
||||
second.Grants["second"] = CollectionGrant{ViewCostumeIDs: []uint64{64902}}
|
||||
if err := collection.commit(second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storage.coreWrites != 1 || len(storage.mutations[2]) != 1 || storage.mutations[2][0].Bucket != "grants" || storage.mutations[2][0].Key != "second" {
|
||||
t.Fatalf("entry-only update rewrote core or unrelated entries: %+v", storage.mutations[2])
|
||||
}
|
||||
loaded, err := OpenCollectionStore(storage, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Characters()) != 1 || len(loaded.Costumes()) != 1 || !loaded.data.GachaApplied["draw:first"] {
|
||||
t.Fatalf("reloaded collection lost entities or applied draw: %+v", loaded.data)
|
||||
}
|
||||
if _, found := loaded.Grant("second"); !found {
|
||||
t.Fatal("reloaded collection lost second grant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionRejectsInlineLedgerInFinalCore(t *testing.T) {
|
||||
storage := stateio.NewMemory()
|
||||
core := map[string]any{
|
||||
"version": "2.35.10", "next_character_index": 920000001,
|
||||
"next_costume_index": 930000001, "grants": map[string]any{},
|
||||
}
|
||||
encoded, err := json.Marshal(core)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := storage.Save("collection", encoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := OpenCollectionStore(storage, nil); err == nil {
|
||||
t.Fatal("old inline collection ledger was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionEntryBucketsRoundTripAndDelete(t *testing.T) {
|
||||
storage := stateio.NewMemory()
|
||||
collection, err := OpenCollectionStore(storage, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := cloneCollection(collection.data)
|
||||
next.Characters = []Character{{InvenIndex: 920000001, ID: 6490, Level: 1}}
|
||||
next.Costumes = []Costume{{InvenIndex: 930000001, ID: 64901, UseChar: 920000001}}
|
||||
next.Grants["draw:1"] = CollectionGrant{ViewCostumeIDs: []uint64{64901}}
|
||||
next.GachaApplied["draw:1"] = true
|
||||
next.GachaUsers["1"] = GachaUserState{GroupID: 1, Point: 20}
|
||||
next.GachaFixed["1:2"] = GachaFixedState{FixedID: 1, Type: 2, Count: 3}
|
||||
next.StepUpProgress["1"] = 2
|
||||
next.GachaPointExchange["exchange:1"] = GachaPointExchange{GroupID: 1, Count: 3}
|
||||
next.GachaSelections["1"] = []GachaSelection{{GroupID: 1, Slot: 1, ItemID: 64901}}
|
||||
next.CostumePotential["930000001"] = []uint64{2, 4}
|
||||
next.CharAwake["6490"] = CharAwakeProgress{ImprintLevels: [3]uint64{1, 0, 0}}
|
||||
if err := collection.commit(next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := OpenCollectionStore(storage, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(loaded.data, next) {
|
||||
t.Fatalf("collection entries changed across reload: loaded=%+v want=%+v", loaded.data, next)
|
||||
}
|
||||
removed := cloneCollection(loaded.data)
|
||||
delete(removed.Grants, "draw:1")
|
||||
delete(removed.GachaApplied, "draw:1")
|
||||
removed.Characters = nil
|
||||
removed.Costumes = nil
|
||||
if err := loaded.commit(removed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for bucket, key := range map[string]string{"grants": "draw:1", "gacha_applied": "draw:1", "characters": "920000001", "costumes": "930000001"} {
|
||||
if _, found, err := storage.LoadEntry("collection", bucket, key); err != nil || found {
|
||||
t.Fatalf("deleted entry %s/%s found=%v err=%v", bucket, key, found, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
)
|
||||
|
||||
func TestEarnedQuestCostumeSharesPotentialLedgerAndKeepsSingleOwnedInstance(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "collection.json")
|
||||
store, err := OpenCollectionStore(testStore(path), []Costume{{InvenIndex: 123, ID: 60101, UseChar: 44}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reward := Costume{InvenIndex: 609338889, ID: 3501, UseChar: 535607162}
|
||||
if err := store.AttachRewardCostume(reward); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := store.Costumes(); len(got) != 2 || got[1].InvenIndex != reward.InvenIndex {
|
||||
t.Fatalf("earned costume view=%+v", got)
|
||||
}
|
||||
if err := store.ActivateCostumePotential(reward.InvenIndex, []uint64{1, 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, found := store.CostumeByIndex(reward.InvenIndex)
|
||||
if !found || len(got.PotentialIDs) != 2 || got.PotentialIDs[0] != 1 || got.PotentialIDs[1] != 2 {
|
||||
t.Fatalf("earned costume potential=%+v found=%v", got, found)
|
||||
}
|
||||
restarted, err := OpenCollectionStore(testStore(path), []Costume{{InvenIndex: 123, ID: 60101, UseChar: 44}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restarted.AttachRewardCostume(reward); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, found = restarted.CostumeByIndex(reward.InvenIndex)
|
||||
if !found || len(got.PotentialIDs) != 2 {
|
||||
t.Fatalf("restarted earned costume potential=%+v found=%v", got, found)
|
||||
}
|
||||
if err := restarted.AttachRewardCostume(reward); err == nil {
|
||||
t.Fatal("duplicate earned quest costume accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCollectionCharacterAcrossPromotionChangesDesignNotInstance(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "collection.json")
|
||||
store, err := OpenCollectionStore(testStore(path), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := cloneCollection(store.data)
|
||||
next.Characters = []Character{{InvenIndex: 920000054, ID: 6510, Level: 1}}
|
||||
if err := store.commit(next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
promoted := Character{InvenIndex: 920000054, ID: 6514, Level: 100}
|
||||
if err := store.CanUpdateCharacter(6510, promoted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.UpdateCharacter(6510, promoted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := OpenCollectionStore(testStore(path), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, found := loaded.FindCharacter(920000054); !found || got.ID != 6514 || got.Level != 100 {
|
||||
t.Fatalf("persisted promoted collection character=%+v found=%v", got, found)
|
||||
}
|
||||
if err := loaded.UpdateCharacter(6510, promoted); err == nil {
|
||||
t.Fatal("stale old design accepted")
|
||||
}
|
||||
if err := loaded.CanUpdateCharacter(6514, Character{InvenIndex: 920000055, ID: 6514, Level: 100}); err == nil {
|
||||
t.Fatal("unknown instance accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxCostumeDuplicatePersistsExchangeAndDisplayUpgrade(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "collection.json")
|
||||
store, err := OpenCollectionStore(testStore(path), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := cloneCollection(store.data)
|
||||
next.Characters = []Character{{InvenIndex: 920000001, ID: 6490, Level: 1, UseCostume: 930000001, ConnectPotentialCostume: 64901}}
|
||||
next.Costumes = []Costume{{InvenIndex: 930000001, ID: 64901, Level: 5, UseChar: 920000001}}
|
||||
if err := store.commit(next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := gamedata.NewRegularGachaCatalog(
|
||||
map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 64901, Weight: 1}}}},
|
||||
map[uint64]gamedata.CharacterDesign{64901: {ID: 6490, HP: 166, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 2}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant, err := store.GrantRegular("draw", []uint64{64901}, catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(grant.Exchanges) != 1 || len(grant.Upgrades) != 1 {
|
||||
t.Fatalf("grant=%+v", grant)
|
||||
}
|
||||
if got := grant.Upgrades[0]; got.InvenIndex != 930000001 || got.CostumeID != 64901 || got.Before != 5 || got.After != 5 || got.SortID != 0 {
|
||||
t.Fatalf("display upgrade=%+v", got)
|
||||
}
|
||||
if got := store.Costumes()[0].Level; got != 5 {
|
||||
t.Fatalf("costume level=%d want=5", got)
|
||||
}
|
||||
restored, err := OpenCollectionStore(testStore(path), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
persisted, ok := restored.Grant("draw")
|
||||
if !ok || len(persisted.Exchanges) != 1 || len(persisted.Upgrades) != 1 || restored.Costumes()[0].Level != 5 {
|
||||
t.Fatalf("persisted=%+v costumes=%+v", persisted, restored.Costumes())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantDifferentCostumesForSameCharacterReusesCharacter(t *testing.T) {
|
||||
store, err := OpenCollectionStore(testStore(filepath.Join(t.TempDir(), "collection.json")), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := gamedata.NewRegularGachaCatalog(
|
||||
map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 64901, Weight: 1}}}},
|
||||
map[uint64]gamedata.CharacterDesign{
|
||||
64901: {ID: 6490, HP: 166, CostumeMaxLevel: 5},
|
||||
64902: {ID: 6490, HP: 166, CostumeMaxLevel: 5},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := store.GrantRegular("first", []uint64{64901}, catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := store.GrantRegular("second", []uint64{64902}, catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first.CharacterIndices) != 1 || len(second.CharacterIndices) != 0 || len(store.Characters()) != 1 {
|
||||
t.Fatalf("first=%+v second=%+v characters=%+v", first, second, store.Characters())
|
||||
}
|
||||
costumes := store.Costumes()
|
||||
if len(costumes) != 2 || costumes[0].UseChar != costumes[1].UseChar || costumes[0].UseChar != store.Characters()[0].InvenIndex {
|
||||
t.Fatalf("costumes=%+v characters=%+v", costumes, store.Characters())
|
||||
}
|
||||
if store.Characters()[0].CostumeID != 64901 {
|
||||
t.Fatalf("initial character costume_id=%d", store.Characters()[0].CostumeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstGachaCompletionIsExplicitAndAtomic(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "collection.json")
|
||||
store, err := OpenCollectionStore(testStore(path), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.FirstGachaCompleted() {
|
||||
t.Fatal("new collection reports completed first gacha")
|
||||
}
|
||||
catalog, err := gamedata.NewRegularGachaCatalog(
|
||||
map[uint64]gamedata.RegularGacha{20: {ID: 20, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 64901, Weight: 1}}}},
|
||||
map[uint64]gamedata.CharacterDesign{64901: {ID: 6490, HP: 166, CostumeMaxLevel: 5}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = store.GrantRegularPurchase("regular-gacha:20:seq:1", []uint64{64901}, catalog, GachaPurchase{
|
||||
Group: gamedata.GachaGroupDesign{ID: 2, GachaSubType: 3},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !store.FirstGachaCompleted() {
|
||||
t.Fatal("subtype-3 purchase did not persist first-gacha completion")
|
||||
}
|
||||
restored, err := OpenCollectionStore(testStore(path), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !restored.FirstGachaCompleted() {
|
||||
t.Fatal("first-gacha completion did not survive restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstGachaCompletionRejectsRewardPayload(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "collection.json")
|
||||
store, err := OpenCollectionStore(testStore(path), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := cloneCollection(store.data)
|
||||
next.Grants[FirstGachaCompletedIdentity] = CollectionGrant{ViewCostumeIDs: []uint64{1}}
|
||||
if err := store.commit(next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := OpenCollectionStore(testStore(path), nil); err == nil {
|
||||
t.Fatal("collection accepted a non-empty first-gacha marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantCostumeForBaseCharacterDoesNotCreateCharacter(t *testing.T) {
|
||||
store, err := OpenCollectionStore(testStore(filepath.Join(t.TempDir(), "collection.json")), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := []Character{{InvenIndex: 535604118, ID: 6490, Level: 1, UseCostume: 635604118}}
|
||||
if err := store.BindBaseCharacters(base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := gamedata.NewRegularGachaCatalog(
|
||||
map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 64902, Weight: 1}}}},
|
||||
map[uint64]gamedata.CharacterDesign{64902: {ID: 6490, HP: 166, CostumeMaxLevel: 5}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant, err := store.GrantRegular("base-costume", []uint64{64902}, catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(grant.CharacterIndices) != 0 || len(store.Characters()) != 0 || len(store.Costumes()) != 1 || store.Costumes()[0].UseChar != base[0].InvenIndex {
|
||||
t.Fatalf("grant=%+v characters=%+v costumes=%+v", grant, store.Characters(), store.Costumes())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type CostumePotentialService struct {
|
||||
design *gamedata.CostumePotentialDesign
|
||||
collection *CollectionStore
|
||||
characters *CharacterStore
|
||||
inventory *Inventory
|
||||
wallet *Wallet
|
||||
}
|
||||
|
||||
func NewCostumePotentialService(design *gamedata.CostumePotentialDesign, collection *CollectionStore, characters *CharacterStore, inventory *Inventory, wallet *Wallet) (*CostumePotentialService, error) {
|
||||
if design == nil || collection == nil || characters == nil || inventory == nil || wallet == nil {
|
||||
return nil, errors.New("player: incomplete costume potential service")
|
||||
}
|
||||
return &CostumePotentialService{design: design, collection: collection, characters: characters, inventory: inventory, wallet: wallet}, nil
|
||||
}
|
||||
|
||||
func (s *CostumePotentialService) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
if path != "/CostumeNodeActivation" {
|
||||
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: CostumeNodeActivation missing sequence")
|
||||
}
|
||||
characterIndex, found, err := wire.Varint(request, 2)
|
||||
if err != nil || !found || characterIndex == 0 {
|
||||
return 0, nil, true, errors.New("player: CostumeNodeActivation missing character")
|
||||
}
|
||||
costumeIndex, found, err := wire.Varint(request, 3)
|
||||
if err != nil || !found || costumeIndex == 0 {
|
||||
return 0, nil, true, errors.New("player: CostumeNodeActivation missing costume")
|
||||
}
|
||||
var nodes []uint64
|
||||
var materials []Item
|
||||
err = wire.Walk(request, func(field wire.Field) error {
|
||||
switch field.Number {
|
||||
case 4:
|
||||
if field.Type != 0 && field.Type != 2 {
|
||||
return errors.New("player: invalid potential node field")
|
||||
}
|
||||
for data := field.Value; len(data) != 0; {
|
||||
id, count := binary.Uvarint(data)
|
||||
if count <= 0 || id == 0 {
|
||||
return errors.New("player: invalid potential node ID")
|
||||
}
|
||||
nodes = append(nodes, id)
|
||||
data = data[count:]
|
||||
}
|
||||
case 5:
|
||||
if field.Type != 2 {
|
||||
return errors.New("player: invalid potential material field")
|
||||
}
|
||||
var item Item
|
||||
if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil {
|
||||
return err
|
||||
}
|
||||
if item.Type == 0 || item.Count == 0 || (item.Type != 4 && (item.InvenIndex == 0 || item.ID == 0)) {
|
||||
return errors.New("player: incomplete potential material")
|
||||
}
|
||||
materials = append(materials, item)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
if len(nodes) == 0 || len(materials) == 0 {
|
||||
return 0, nil, true, errors.New("player: CostumeNodeActivation has no nodes or materials")
|
||||
}
|
||||
character, found := s.characters.Find(characterIndex)
|
||||
if !found {
|
||||
return 0, nil, true, fmt.Errorf("player: unknown potential character %d", characterIndex)
|
||||
}
|
||||
costume, found := s.collection.CostumeByIndex(costumeIndex)
|
||||
if !found || costume.UseChar != characterIndex {
|
||||
return 0, nil, true, fmt.Errorf("player: potential costume %d is not owned by character %d", costumeIndex, characterIndex)
|
||||
}
|
||||
if err := s.collection.ValidateCostumePotentialActivation(costumeIndex, nodes); err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
costs, err := s.design.Validate(costume.ID, character.ID, 0, costume.PotentialIDs, nodes)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate costume potential GameData: %w", err)
|
||||
}
|
||||
want := make(map[[2]uint64]uint64)
|
||||
for _, cost := range costs {
|
||||
key := [2]uint64{cost.Type, cost.ID}
|
||||
if cost.Count > ^uint64(0)-want[key] {
|
||||
return 0, nil, true, errors.New("player: costume potential cost overflow")
|
||||
}
|
||||
want[key] += cost.Count
|
||||
}
|
||||
got := make(map[[2]uint64]uint64)
|
||||
var items []Item
|
||||
var gold uint64
|
||||
for _, material := range materials {
|
||||
key := [2]uint64{material.Type, material.ID}
|
||||
if material.Type == 4 {
|
||||
if material.InvenIndex != 0 || material.ID != 0 || gold != 0 {
|
||||
return 0, nil, true, errors.New("player: invalid costume potential currency")
|
||||
}
|
||||
gold = material.Count
|
||||
} else {
|
||||
if material.Type != 8 {
|
||||
return 0, nil, true, fmt.Errorf("player: unsupported costume potential material type %d", material.Type)
|
||||
}
|
||||
items = append(items, material)
|
||||
}
|
||||
if material.Count > ^uint64(0)-got[key] {
|
||||
return 0, nil, true, errors.New("player: submitted costume potential material overflow")
|
||||
}
|
||||
got[key] += material.Count
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
return 0, nil, true, fmt.Errorf("player: costume potential material kinds mismatch: request=%v GameData=%v", got, want)
|
||||
}
|
||||
for key, count := range want {
|
||||
if got[key] != count {
|
||||
return 0, nil, true, fmt.Errorf("player: costume potential material %d/%d=%d want %d", key[0], key[1], got[key], count)
|
||||
}
|
||||
}
|
||||
if len(items) != 0 {
|
||||
if err := s.inventory.CanConsume(items); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate costume potential items: %w", err)
|
||||
}
|
||||
}
|
||||
if gold != 0 && !s.wallet.CanSpendGold(gold) {
|
||||
return 0, nil, true, errors.New("player: insufficient gold for costume potential")
|
||||
}
|
||||
sortedNodes := append([]uint64(nil), nodes...)
|
||||
sort.Slice(sortedNodes, func(i, j int) bool { return sortedNodes[i] < sortedNodes[j] })
|
||||
parts := make([]string, len(sortedNodes))
|
||||
for i, id := range sortedNodes {
|
||||
parts[i] = strconv.FormatUint(id, 10)
|
||||
}
|
||||
identity := "costume-potential:" + strconv.FormatUint(costumeIndex, 10) + ":" + strings.Join(parts, ",")
|
||||
if gold != 0 {
|
||||
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: spend costume potential gold: %w", err)
|
||||
}
|
||||
}
|
||||
if len(items) != 0 {
|
||||
if err := s.inventory.Consume(items); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: consume costume potential items: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.collection.ActivateCostumePotential(costumeIndex, nodes); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: persist costume potential: %w", err)
|
||||
}
|
||||
return 261, nil, true, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestCostumeNodeActivationSupportsSingleAndOneClickSets(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
materials, err := inventory.GrantOnce("potential", []gamedata.BattleReward{{Type: 8, ID: 114, Count: 5}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 300})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: 6514, Level: 100}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection, err := OpenCollectionStore(testStore(filepath.Join(dir, "collection.json")), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nextCollection := cloneCollection(collection.data)
|
||||
nextCollection.Costumes = []Costume{{InvenIndex: 88, ID: 65103, UseChar: 77}}
|
||||
if err := collection.commit(nextCollection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := &gamedata.CostumePotentialDesign{
|
||||
Nodes: map[uint64]map[uint64]gamedata.CostumePotentialNode{65103: {
|
||||
1: {ID: 1, Costs: []gamedata.CostumePotentialCost{{Type: 4, Count: 100}}},
|
||||
2: {ID: 2, Prerequisites: []uint64{1}, Costs: []gamedata.CostumePotentialCost{{Type: 8, ID: 114, Count: 2}}},
|
||||
3: {ID: 3, Prerequisites: []uint64{2}, Costs: []gamedata.CostumePotentialCost{{Type: 8, ID: 114, Count: 3}, {Type: 4, Count: 200}}},
|
||||
}},
|
||||
CostumeUnique: map[uint64]uint64{65103: 651},
|
||||
CharacterGrade: map[uint64]uint64{6514: 5},
|
||||
CharacterUnique: map[uint64]uint64{6514: 651},
|
||||
}
|
||||
service, err := NewCostumePotentialService(design, collection, characters, inventory, wallet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77), 3, 88)
|
||||
request = wire.AppendVarint(request, 4, 1)
|
||||
request = wire.AppendBytes(request, 5, ItemWire(Item{Type: 4, Count: 100}))
|
||||
code, response, handled, err := service.Handle("/CostumeNodeActivation", request)
|
||||
if err != nil || !handled || code != 261 || len(response) != 0 {
|
||||
t.Fatalf("single activation code=%d handled=%v response=%x err=%v", code, handled, response, err)
|
||||
}
|
||||
oneClick := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 77), 3, 88)
|
||||
oneClick = wire.AppendVarint(wire.AppendVarint(oneClick, 4, 2), 4, 3)
|
||||
item := materials[0]
|
||||
oneClick = wire.AppendBytes(oneClick, 5, ItemWire(item))
|
||||
oneClick = wire.AppendBytes(oneClick, 5, ItemWire(Item{Type: 4, Count: 200}))
|
||||
code, _, handled, err = service.Handle("/CostumeNodeActivation", oneClick)
|
||||
if err != nil || !handled || code != 261 {
|
||||
t.Fatalf("one-click activation code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
if wallet.Snapshot().Gold != 0 {
|
||||
t.Fatalf("gold=%d", wallet.Snapshot().Gold)
|
||||
}
|
||||
costume, found := collection.CostumeByIndex(88)
|
||||
if !found || len(costume.PotentialIDs) != 3 || costume.PotentialIDs[0] != 1 || costume.PotentialIDs[2] != 3 {
|
||||
t.Fatalf("activated costume=%+v found=%v", costume, found)
|
||||
}
|
||||
var encoded []uint64
|
||||
if err := wire.Walk(CostumeWire(costume), func(field wire.Field) error {
|
||||
if field.Number == 8 {
|
||||
v, _ := binary.Uvarint(field.Value)
|
||||
encoded = append(encoded, v)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(encoded) != 3 {
|
||||
t.Fatalf("wire potential IDs=%v", encoded)
|
||||
}
|
||||
if _, _, _, err := service.Handle("/CostumeNodeActivation", oneClick); err == nil {
|
||||
t.Fatal("already active nodes accepted")
|
||||
}
|
||||
if wallet.Snapshot().Gold != 0 {
|
||||
t.Fatal("duplicate request charged wallet")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
)
|
||||
|
||||
func loadBoolEntries(store stateio.AtomicEntryStore, domain, bucket string) (map[string]bool, error) {
|
||||
entries, err := store.ListEntries(domain, bucket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]bool, len(entries))
|
||||
for key, payload := range entries {
|
||||
if key == "" || string(payload) != "true" {
|
||||
return nil, fmt.Errorf("player: invalid %s %s entry %q", domain, bucket, key)
|
||||
}
|
||||
result[key] = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadUintEntries(store stateio.AtomicEntryStore, domain, bucket string) (map[string]uint64, error) {
|
||||
entries, err := store.ListEntries(domain, bucket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]uint64, len(entries))
|
||||
for key, payload := range entries {
|
||||
var value uint64
|
||||
if key == "" || json.Unmarshal(payload, &value) != nil || value == 0 {
|
||||
return nil, fmt.Errorf("player: invalid %s %s entry %q", domain, bucket, key)
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func entry(bucket, key string, payload []byte) []stateio.EntryMutation {
|
||||
return []stateio.EntryMutation{{Bucket: bucket, Key: key, Payload: payload}}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,453 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
const equipmentSlotCount = 5
|
||||
|
||||
type equipmentBatchUse struct {
|
||||
CharacterIndex uint64
|
||||
Equipment []uint64
|
||||
}
|
||||
|
||||
type equipmentPresetKey struct {
|
||||
CharacterIndex uint64
|
||||
Slot uint64
|
||||
}
|
||||
|
||||
type equipmentPresetItem struct {
|
||||
Type uint64 `json:"type"`
|
||||
EquipmentIndex uint64 `json:"equipment_index"`
|
||||
}
|
||||
|
||||
type equipmentPreset struct {
|
||||
CharacterIndex uint64 `json:"character_index"`
|
||||
Slot uint64 `json:"slot"`
|
||||
Name string `json:"name"`
|
||||
ResourceID uint64 `json:"resource_id"`
|
||||
ResourceColor uint64 `json:"resource_color"`
|
||||
Items []equipmentPresetItem `json:"items"`
|
||||
}
|
||||
|
||||
func (s *EquipmentInventory) batchUse(request []byte) (int, []byte, bool, error) {
|
||||
entries, err := decodeEquipmentBatchUse(request)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
if s.characters == nil {
|
||||
return 0, nil, true, errors.New("player: EquipBatchUse character store unavailable")
|
||||
}
|
||||
requestedCharacters := make(map[uint64]bool, len(entries))
|
||||
for _, entry := range entries {
|
||||
if requestedCharacters[entry.CharacterIndex] {
|
||||
return 0, nil, true, fmt.Errorf("player: EquipBatchUse repeats character %d", entry.CharacterIndex)
|
||||
}
|
||||
requestedCharacters[entry.CharacterIndex] = true
|
||||
if _, found := s.characters.Find(entry.CharacterIndex); !found {
|
||||
return 0, nil, true, fmt.Errorf("player: EquipBatchUse unknown character %d", entry.CharacterIndex)
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if len(s.slots) == 0 {
|
||||
s.mu.Unlock()
|
||||
return 0, nil, true, errors.New("player: EquipBatchUse slot design unavailable")
|
||||
}
|
||||
desired := make(map[uint64]uint64)
|
||||
for _, entry := range entries {
|
||||
for slot, index := range entry.Equipment {
|
||||
if index == 0 {
|
||||
continue
|
||||
}
|
||||
if desired[index] != 0 {
|
||||
s.mu.Unlock()
|
||||
return 0, nil, true, fmt.Errorf("player: EquipBatchUse repeats equipment %d", index)
|
||||
}
|
||||
position := s.equipmentPositionLocked(index)
|
||||
if position < 0 {
|
||||
s.mu.Unlock()
|
||||
return 0, nil, true, fmt.Errorf("player: EquipBatchUse unknown equipment %d", index)
|
||||
}
|
||||
item := s.owned.Equipment[position]
|
||||
if designedSlot, ok := s.slots[item.ID]; !ok || designedSlot != uint64(slot) {
|
||||
s.mu.Unlock()
|
||||
return 0, nil, true, fmt.Errorf("player: EquipBatchUse equipment %d does not belong in slot %d", index, slot)
|
||||
}
|
||||
if item.UseChar != 0 && item.UseChar != entry.CharacterIndex && !requestedCharacters[item.UseChar] {
|
||||
s.mu.Unlock()
|
||||
return 0, nil, true, fmt.Errorf("player: EquipBatchUse equipment %d is still used by character %d", index, item.UseChar)
|
||||
}
|
||||
desired[index] = entry.CharacterIndex
|
||||
}
|
||||
}
|
||||
|
||||
next := cloneEquipmentSnapshot(s.owned)
|
||||
affected := make(map[uint64]bool, len(entries))
|
||||
for character := range requestedCharacters {
|
||||
affected[character] = true
|
||||
}
|
||||
for i := range next.Equipment {
|
||||
item := &next.Equipment[i]
|
||||
if requestedCharacters[item.UseChar] {
|
||||
item.UseChar = 0
|
||||
}
|
||||
}
|
||||
for i := range next.Equipment {
|
||||
item := &next.Equipment[i]
|
||||
character := desired[item.InvenIndex]
|
||||
if character == 0 {
|
||||
continue
|
||||
}
|
||||
if item.UseChar != 0 && item.UseChar != character {
|
||||
affected[item.UseChar] = true
|
||||
}
|
||||
item.UseChar = character
|
||||
}
|
||||
if err := s.commitLocked(next, "batch use"); err != nil {
|
||||
s.mu.Unlock()
|
||||
return 0, nil, true, err
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
characterIndices := make([]uint64, 0, len(affected))
|
||||
for index := range affected {
|
||||
characterIndices = append(characterIndices, index)
|
||||
}
|
||||
sort.Slice(characterIndices, func(i, j int) bool { return characterIndices[i] < characterIndices[j] })
|
||||
var response []byte
|
||||
for _, index := range characterIndices {
|
||||
if character, found := s.characters.Find(index); found {
|
||||
response = wire.AppendBytes(response, 1, CharacterWire(character))
|
||||
}
|
||||
}
|
||||
return 276, response, true, nil
|
||||
}
|
||||
|
||||
func decodeEquipmentBatchUse(request []byte) ([]equipmentBatchUse, error) {
|
||||
var result []equipmentBatchUse
|
||||
err := wire.Walk(request, func(field wire.Field) error {
|
||||
if field.Number != 2 {
|
||||
return nil
|
||||
}
|
||||
if field.Type != 2 {
|
||||
return errors.New("player: EquipBatchUse invalid batch entry")
|
||||
}
|
||||
entry := equipmentBatchUse{}
|
||||
var characterSeen bool
|
||||
if err := wire.Walk(field.Value, func(nested wire.Field) error {
|
||||
switch nested.Number {
|
||||
case 1:
|
||||
if characterSeen || nested.Type != 0 {
|
||||
return errors.New("player: EquipBatchUse invalid character")
|
||||
}
|
||||
entry.CharacterIndex, _ = binary.Uvarint(nested.Value)
|
||||
characterSeen = true
|
||||
case 2:
|
||||
values, err := decodeRepeatedUint64(nested)
|
||||
if err != nil {
|
||||
return errors.New("player: EquipBatchUse invalid equipment list")
|
||||
}
|
||||
entry.Equipment = append(entry.Equipment, values...)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if !characterSeen || entry.CharacterIndex == 0 || len(entry.Equipment) != equipmentSlotCount {
|
||||
return errors.New("player: EquipBatchUse requires one character and five equipment slots")
|
||||
}
|
||||
result = append(result, entry)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, errors.New("player: EquipBatchUse has no batch entries")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeRepeatedUint64(field wire.Field) ([]uint64, error) {
|
||||
switch field.Type {
|
||||
case 0:
|
||||
value, count := binary.Uvarint(field.Value)
|
||||
if count <= 0 {
|
||||
return nil, wire.ErrMalformed
|
||||
}
|
||||
return []uint64{value}, nil
|
||||
case 2:
|
||||
var result []uint64
|
||||
for offset := 0; offset < len(field.Value); {
|
||||
value, count := binary.Uvarint(field.Value[offset:])
|
||||
if count <= 0 {
|
||||
return nil, wire.ErrMalformed
|
||||
}
|
||||
result = append(result, value)
|
||||
offset += count
|
||||
}
|
||||
return result, nil
|
||||
default:
|
||||
return nil, wire.ErrMalformed
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EquipmentInventory) loadEquipmentPresets(entries stateio.EntryStore) error {
|
||||
raw, err := entries.ListEntries("equipment", "presets")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, payload := range raw {
|
||||
parsed, err := parseEquipmentPresetKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := stateio.RequireExactJSONObject(payload, "character_index", "slot", "name", "resource_id", "resource_color", "items"); err != nil {
|
||||
return fmt.Errorf("player: incompatible equipment preset %q: %w", key, err)
|
||||
}
|
||||
var preset equipmentPreset
|
||||
if err := json.Unmarshal(payload, &preset); err != nil || preset.CharacterIndex != parsed.CharacterIndex || preset.Slot != parsed.Slot {
|
||||
return fmt.Errorf("player: invalid equipment preset %q", key)
|
||||
}
|
||||
if err := validateEquipmentPresetShape(preset); err != nil {
|
||||
return fmt.Errorf("player: invalid equipment preset %q: %w", key, err)
|
||||
}
|
||||
s.presets[parsed] = preset
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseEquipmentPresetKey(value string) (equipmentPresetKey, error) {
|
||||
left, right, found := strings.Cut(value, ":")
|
||||
if !found {
|
||||
return equipmentPresetKey{}, fmt.Errorf("player: invalid equipment preset key %q", value)
|
||||
}
|
||||
character, characterErr := strconv.ParseUint(left, 10, 64)
|
||||
slot, slotErr := strconv.ParseUint(right, 10, 64)
|
||||
if characterErr != nil || slotErr != nil || character == 0 || slot < 1 || slot > 5 || value != equipmentPresetStorageKey(equipmentPresetKey{CharacterIndex: character, Slot: slot}) {
|
||||
return equipmentPresetKey{}, fmt.Errorf("player: invalid equipment preset key %q", value)
|
||||
}
|
||||
return equipmentPresetKey{CharacterIndex: character, Slot: slot}, nil
|
||||
}
|
||||
|
||||
func equipmentPresetStorageKey(key equipmentPresetKey) string {
|
||||
return strconv.FormatUint(key.CharacterIndex, 10) + ":" + strconv.FormatUint(key.Slot, 10)
|
||||
}
|
||||
|
||||
func validateEquipmentPresetShape(preset equipmentPreset) error {
|
||||
if preset.CharacterIndex == 0 || preset.Slot < 1 || preset.Slot > 5 || !utf8.ValidString(preset.Name) ||
|
||||
utf8.RuneCountInString(preset.Name) < 1 || utf8.RuneCountInString(preset.Name) > 16 ||
|
||||
preset.ResourceID < 1 || preset.ResourceID > 21 || preset.ResourceColor > 5 || len(preset.Items) != equipmentSlotCount {
|
||||
return errors.New("invalid fields")
|
||||
}
|
||||
seen := make(map[uint64]bool, equipmentSlotCount)
|
||||
for _, item := range preset.Items {
|
||||
if item.Type >= equipmentSlotCount || seen[item.Type] {
|
||||
return errors.New("invalid equipment slots")
|
||||
}
|
||||
seen[item.Type] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EquipmentInventory) presetInfo() (int, []byte, bool, error) {
|
||||
s.mu.Lock()
|
||||
presets := make([]equipmentPreset, 0, len(s.presets))
|
||||
for _, preset := range s.presets {
|
||||
presets = append(presets, cloneEquipmentPreset(preset))
|
||||
}
|
||||
s.mu.Unlock()
|
||||
sort.Slice(presets, func(i, j int) bool {
|
||||
if presets[i].CharacterIndex != presets[j].CharacterIndex {
|
||||
return presets[i].CharacterIndex < presets[j].CharacterIndex
|
||||
}
|
||||
return presets[i].Slot < presets[j].Slot
|
||||
})
|
||||
var response []byte
|
||||
for i := 0; i < len(presets); {
|
||||
character := presets[i].CharacterIndex
|
||||
var encoded []byte
|
||||
encoded = wire.AppendVarint(encoded, 1, character)
|
||||
for i < len(presets) && presets[i].CharacterIndex == character {
|
||||
encoded = wire.AppendBytes(encoded, 2, equipmentPresetWire(presets[i]))
|
||||
i++
|
||||
}
|
||||
response = wire.AppendBytes(response, 1, encoded)
|
||||
}
|
||||
return 253, response, true, nil
|
||||
}
|
||||
|
||||
func equipmentPresetWire(preset equipmentPreset) []byte {
|
||||
var out []byte
|
||||
if preset.Name != "" {
|
||||
out = wire.AppendString(out, 1, preset.Name)
|
||||
}
|
||||
out = wire.AppendVarint(out, 2, preset.Slot)
|
||||
if preset.ResourceID != 0 {
|
||||
out = wire.AppendVarint(out, 3, preset.ResourceID)
|
||||
}
|
||||
if preset.ResourceColor != 0 {
|
||||
out = wire.AppendVarint(out, 4, preset.ResourceColor)
|
||||
}
|
||||
for _, item := range preset.Items {
|
||||
var encoded []byte
|
||||
if item.Type != 0 {
|
||||
encoded = wire.AppendVarint(encoded, 1, item.Type)
|
||||
}
|
||||
if item.EquipmentIndex != 0 {
|
||||
encoded = wire.AppendVarint(encoded, 2, item.EquipmentIndex)
|
||||
}
|
||||
out = wire.AppendBytes(out, 5, encoded)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *EquipmentInventory) presetSave(request []byte) (int, []byte, bool, error) {
|
||||
preset, err := decodeEquipmentPresetRequest(request, true)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
if s.characters == nil {
|
||||
return 0, nil, true, errors.New("player: EquipPresetSave character store unavailable")
|
||||
}
|
||||
if _, found := s.characters.Find(preset.CharacterIndex); !found {
|
||||
return 0, nil, true, fmt.Errorf("player: EquipPresetSave unknown character %d", preset.CharacterIndex)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.slots) == 0 {
|
||||
return 0, nil, true, errors.New("player: EquipPresetSave slot design unavailable")
|
||||
}
|
||||
for _, item := range preset.Items {
|
||||
if item.EquipmentIndex == 0 {
|
||||
continue
|
||||
}
|
||||
position := s.equipmentPositionLocked(item.EquipmentIndex)
|
||||
if position < 0 {
|
||||
return 0, nil, true, fmt.Errorf("player: EquipPresetSave unknown equipment %d", item.EquipmentIndex)
|
||||
}
|
||||
equipment := s.owned.Equipment[position]
|
||||
if s.slots[equipment.ID] != item.Type {
|
||||
return 0, nil, true, fmt.Errorf("player: EquipPresetSave equipment %d does not match character slot", item.EquipmentIndex)
|
||||
}
|
||||
}
|
||||
if err := s.persistEquipmentPreset(preset, "save"); err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
s.presets[equipmentPresetKey{CharacterIndex: preset.CharacterIndex, Slot: preset.Slot}] = cloneEquipmentPreset(preset)
|
||||
return 254, nil, true, nil
|
||||
}
|
||||
|
||||
func (s *EquipmentInventory) presetNameChange(request []byte) (int, []byte, bool, error) {
|
||||
metadata, err := decodeEquipmentPresetRequest(request, false)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
key := equipmentPresetKey{CharacterIndex: metadata.CharacterIndex, Slot: metadata.Slot}
|
||||
if s.characters == nil {
|
||||
return 0, nil, true, errors.New("player: EquipPresetNameChange character store unavailable")
|
||||
}
|
||||
if _, found := s.characters.Find(metadata.CharacterIndex); !found {
|
||||
return 0, nil, true, fmt.Errorf("player: EquipPresetNameChange unknown character %d", metadata.CharacterIndex)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
preset, found := s.presets[key]
|
||||
if !found {
|
||||
preset = metadata
|
||||
}
|
||||
preset.Name = metadata.Name
|
||||
preset.ResourceID = metadata.ResourceID
|
||||
preset.ResourceColor = metadata.ResourceColor
|
||||
if err := s.persistEquipmentPreset(preset, "name change"); err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
s.presets[key] = cloneEquipmentPreset(preset)
|
||||
return 259, nil, true, nil
|
||||
}
|
||||
|
||||
func decodeEquipmentPresetRequest(request []byte, withItems bool) (equipmentPreset, error) {
|
||||
character, found, err := wire.Varint(request, 2)
|
||||
if err != nil || !found || character == 0 {
|
||||
return equipmentPreset{}, errors.New("player: equipment preset request missing character")
|
||||
}
|
||||
slot, found, err := wire.Varint(request, 3)
|
||||
if err != nil || !found || slot < 1 || slot > 5 {
|
||||
return equipmentPreset{}, errors.New("player: equipment preset request invalid slot")
|
||||
}
|
||||
nameBytes, _, err := wire.Bytes(request, 4)
|
||||
if err != nil || !utf8.Valid(nameBytes) {
|
||||
return equipmentPreset{}, errors.New("player: equipment preset request invalid name")
|
||||
}
|
||||
resourceID, _, err := wire.Varint(request, 5)
|
||||
if err != nil || resourceID < 1 || resourceID > 21 {
|
||||
return equipmentPreset{}, errors.New("player: equipment preset request invalid resource")
|
||||
}
|
||||
color, _, err := wire.Varint(request, 6)
|
||||
if err != nil || color > 5 {
|
||||
return equipmentPreset{}, errors.New("player: equipment preset request invalid color")
|
||||
}
|
||||
preset := equipmentPreset{CharacterIndex: character, Slot: slot, Name: string(nameBytes), ResourceID: resourceID, ResourceColor: color}
|
||||
if withItems {
|
||||
err = wire.Walk(request, func(field wire.Field) error {
|
||||
if field.Number != 7 {
|
||||
return nil
|
||||
}
|
||||
if field.Type != 2 {
|
||||
return errors.New("player: EquipPresetSave invalid equipment entry")
|
||||
}
|
||||
typeID, _, err := wire.Varint(field.Value, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index, _, err := wire.Varint(field.Value, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preset.Items = append(preset.Items, equipmentPresetItem{Type: typeID, EquipmentIndex: index})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return equipmentPreset{}, err
|
||||
}
|
||||
} else {
|
||||
preset.Items = make([]equipmentPresetItem, equipmentSlotCount)
|
||||
for i := range preset.Items {
|
||||
preset.Items[i].Type = uint64(i)
|
||||
}
|
||||
}
|
||||
if err := validateEquipmentPresetShape(preset); err != nil {
|
||||
return equipmentPreset{}, fmt.Errorf("player: equipment preset request: %w", err)
|
||||
}
|
||||
sort.Slice(preset.Items, func(i, j int) bool { return preset.Items[i].Type < preset.Items[j].Type })
|
||||
return preset, nil
|
||||
}
|
||||
|
||||
func (s *EquipmentInventory) persistEquipmentPreset(preset equipmentPreset, operation string) error {
|
||||
payload, err := json.Marshal(preset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := equipmentPresetKey{CharacterIndex: preset.CharacterIndex, Slot: preset.Slot}
|
||||
change := stateio.EntryMutation{Bucket: "presets", Key: equipmentPresetStorageKey(key), Payload: payload}
|
||||
if err := s.store.SaveWithEntries("equipment", nil, []stateio.EntryMutation{change}); err != nil {
|
||||
return fmt.Errorf("player: persist equipment preset %s: %w", operation, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneEquipmentPreset(preset equipmentPreset) equipmentPreset {
|
||||
preset.Items = append([]equipmentPresetItem(nil), preset.Items...)
|
||||
return preset
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestEquipmentBatchUseOneClickClearPreservesUnfilteredSlots(t *testing.T) {
|
||||
fixture := newEquipmentBatchUseFixture(t)
|
||||
weapon := fixture.grant(t, "clear-weapon", 10, 100)
|
||||
armor := fixture.grant(t, "clear-armor", 11, 100)
|
||||
|
||||
code, response, handled, err := fixture.equipment.Handle("/EquipBatchUse", equipmentBatchUseRequest(
|
||||
equipmentBatchUseEntry(100, 0, armor.InvenIndex, 0, 0, 0),
|
||||
))
|
||||
if err != nil || !handled || code != 276 {
|
||||
t.Fatalf("batch clear code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
if got := responseCharacterIndices(t, response); !reflect.DeepEqual(got, []uint64{100}) {
|
||||
t.Fatalf("response characters=%v", got)
|
||||
}
|
||||
assertEquipmentOwners(t, fixture.equipment, map[uint64]uint64{
|
||||
weapon.InvenIndex: 0,
|
||||
armor.InvenIndex: 100,
|
||||
})
|
||||
}
|
||||
|
||||
func TestEquipmentBatchUseAutoMountReplacesOnlyRequestedSlot(t *testing.T) {
|
||||
fixture := newEquipmentBatchUseFixture(t)
|
||||
oldWeapon := fixture.grant(t, "auto-old-weapon", 10, 100)
|
||||
armor := fixture.grant(t, "auto-armor", 11, 100)
|
||||
newWeapon := fixture.grant(t, "auto-new-weapon", 12, 0)
|
||||
|
||||
code, response, handled, err := fixture.equipment.Handle("/EquipBatchUse", equipmentBatchUseRequest(
|
||||
equipmentBatchUseEntry(100, newWeapon.InvenIndex, armor.InvenIndex, 0, 0, 0),
|
||||
))
|
||||
if err != nil || !handled || code != 276 {
|
||||
t.Fatalf("auto mount code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
if got := responseCharacterIndices(t, response); !reflect.DeepEqual(got, []uint64{100}) {
|
||||
t.Fatalf("response characters=%v", got)
|
||||
}
|
||||
assertEquipmentOwners(t, fixture.equipment, map[uint64]uint64{
|
||||
oldWeapon.InvenIndex: 0,
|
||||
armor.InvenIndex: 100,
|
||||
newWeapon.InvenIndex: 100,
|
||||
})
|
||||
}
|
||||
|
||||
func TestEquipmentBatchUseTransfersAcrossCharactersReturnsBothAndPersists(t *testing.T) {
|
||||
fixture := newEquipmentBatchUseFixture(t)
|
||||
transferred := fixture.grant(t, "cross-character", 10, 200)
|
||||
|
||||
code, response, handled, err := fixture.equipment.Handle("/EquipBatchUse", equipmentBatchUseRequest(
|
||||
equipmentBatchUseEntry(200, 0, 0, 0, 0, 0),
|
||||
equipmentBatchUseEntry(100, transferred.InvenIndex, 0, 0, 0, 0),
|
||||
))
|
||||
if err != nil || !handled || code != 276 {
|
||||
t.Fatalf("cross-character batch code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
// The target changes and the previous owner loses equipment. Both
|
||||
// characters must be returned so the client can refresh their derived HP.
|
||||
if got := responseCharacterIndices(t, response); !reflect.DeepEqual(got, []uint64{100, 200}) {
|
||||
t.Fatalf("response characters=%v", got)
|
||||
}
|
||||
assertEquipmentOwners(t, fixture.equipment, map[uint64]uint64{transferred.InvenIndex: 100})
|
||||
|
||||
restarted, err := OpenEquipmentInventory(testStore(fixture.equipmentPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restarted.AttachSlots(fixture.slots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restarted.AttachCharacters(fixture.characters); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertEquipmentOwners(t, restarted, map[uint64]uint64{transferred.InvenIndex: 100})
|
||||
}
|
||||
|
||||
func TestEquipmentBatchUseRejectsInvalidSnapshotsAtomically(t *testing.T) {
|
||||
fixture := newEquipmentBatchUseFixture(t)
|
||||
weapon := fixture.grant(t, "invalid-weapon", 10, 100)
|
||||
armor := fixture.grant(t, "invalid-armor", 11, 100)
|
||||
want := fixture.equipment.All()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
request []byte
|
||||
}{
|
||||
{
|
||||
name: "duplicate equipment",
|
||||
request: equipmentBatchUseRequest(
|
||||
equipmentBatchUseEntry(100, weapon.InvenIndex, weapon.InvenIndex, 0, 0, 0),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "equipment in wrong slot",
|
||||
request: equipmentBatchUseRequest(
|
||||
equipmentBatchUseEntry(100, 0, weapon.InvenIndex, 0, 0, 0),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "unknown equipment",
|
||||
request: equipmentBatchUseRequest(
|
||||
equipmentBatchUseEntry(100, 999999999, armor.InvenIndex, 0, 0, 0),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "unknown character",
|
||||
request: equipmentBatchUseRequest(
|
||||
equipmentBatchUseEntry(999, weapon.InvenIndex, armor.InvenIndex, 0, 0, 0),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "transfer without previous owner snapshot",
|
||||
request: equipmentBatchUseRequest(
|
||||
equipmentBatchUseEntry(200, weapon.InvenIndex, 0, 0, 0, 0),
|
||||
),
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, _, handled, err := fixture.equipment.Handle("/EquipBatchUse", test.request); err == nil || !handled {
|
||||
t.Fatalf("invalid batch handled=%v err=%v", handled, err)
|
||||
}
|
||||
if got := fixture.equipment.All(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("invalid batch mutated state:\n got=%+v\nwant=%+v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Reopening the store proves rejected requests also left no partial disk
|
||||
// mutation behind.
|
||||
restarted, err := OpenEquipmentInventory(testStore(fixture.equipmentPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := restarted.All(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("invalid batches mutated persisted state:\n got=%+v\nwant=%+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type equipmentBatchUseFixture struct {
|
||||
equipmentPath string
|
||||
equipment *EquipmentInventory
|
||||
characters *CharacterStore
|
||||
slots map[uint64]uint64
|
||||
}
|
||||
|
||||
func newEquipmentBatchUseFixture(t *testing.T) *equipmentBatchUseFixture {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{
|
||||
{InvenIndex: 100, ID: 350, Level: 20},
|
||||
{InvenIndex: 200, ID: 351, Level: 20},
|
||||
}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "equipment.json")
|
||||
equipment, err := OpenEquipmentInventory(testStore(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slots := map[uint64]uint64{10: 0, 11: 1, 12: 0, 13: 2, 14: 3, 15: 4}
|
||||
if err := equipment.AttachSlots(slots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := equipment.AttachCharacters(characters); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &equipmentBatchUseFixture{
|
||||
equipmentPath: path,
|
||||
equipment: equipment,
|
||||
characters: characters,
|
||||
slots: slots,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *equipmentBatchUseFixture) grant(t *testing.T, identity string, designID, character uint64) Equipment {
|
||||
t.Helper()
|
||||
entry, err := f.equipment.GrantOnce(identity, designID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if character == 0 {
|
||||
return entry
|
||||
}
|
||||
f.equipment.mu.Lock()
|
||||
next := cloneEquipmentSnapshot(f.equipment.owned)
|
||||
for i := range next.Equipment {
|
||||
if next.Equipment[i].InvenIndex == entry.InvenIndex {
|
||||
next.Equipment[i].UseChar = character
|
||||
entry = next.Equipment[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := f.equipment.commitLocked(next, "batch-use test setup"); err != nil {
|
||||
f.equipment.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.equipment.mu.Unlock()
|
||||
return entry
|
||||
}
|
||||
|
||||
func equipmentBatchUseRequest(entries ...[]byte) []byte {
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
for _, entry := range entries {
|
||||
request = wire.AppendBytes(request, 2, entry)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func equipmentBatchUseEntry(character uint64, equipment ...uint64) []byte {
|
||||
entry := wire.AppendVarint(nil, 1, character)
|
||||
for _, index := range equipment {
|
||||
entry = wire.AppendVarint(entry, 2, index)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func responseCharacterIndices(t *testing.T, response []byte) []uint64 {
|
||||
t.Helper()
|
||||
var result []uint64
|
||||
if err := wire.Walk(response, func(field wire.Field) error {
|
||||
if field.Number != 1 {
|
||||
return nil
|
||||
}
|
||||
index, found, err := wire.Varint(field.Value, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || index == 0 {
|
||||
t.Fatalf("response contains invalid CharDBInfo")
|
||||
}
|
||||
result = append(result, index)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
|
||||
return result
|
||||
}
|
||||
|
||||
func assertEquipmentOwners(t *testing.T, inventory *EquipmentInventory, want map[uint64]uint64) {
|
||||
t.Helper()
|
||||
got := make(map[uint64]uint64)
|
||||
for _, equipment := range inventory.All() {
|
||||
got[equipment.InvenIndex] = equipment.UseChar
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("equipment owners=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/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/server/gamedata"
|
||||
"bd2server/internal/server/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.35.10"})
|
||||
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.35.10"})
|
||||
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.35.10"})
|
||||
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},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestEquipmentOptionRerollLocksChargesReplaysAndRecoversPending(t *testing.T) {
|
||||
fixture := newEquipmentOptionRerollFixture(t)
|
||||
fixture.equipment.BeginSession("option-reroll-session")
|
||||
|
||||
request := fixture.rerollRequest(41, []bool{false, true}, []bool{false, true, false})
|
||||
if rerollType, found, err := wire.Varint(request, 6); err != nil || !found || rerollType != 1 {
|
||||
t.Fatalf("malformed test reroll type=%d found=%v err=%v request=%x", rerollType, found, err, request)
|
||||
}
|
||||
code, response, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", request)
|
||||
if err != nil || !handled || code != 192 {
|
||||
t.Fatalf("reroll code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
wantMain := []EquipmentOption{{GroupID: 100, ID: 1}, {GroupID: 101, ID: 2}}
|
||||
wantSub := []EquipmentOption{{GroupID: 200, ID: 11}, {GroupID: 201, ID: 20}, {GroupID: 202, ID: 31}}
|
||||
main, sub := optionRerollResponseOptions(t, response)
|
||||
if !reflect.DeepEqual(main, wantMain) || !reflect.DeepEqual(sub, wantSub) {
|
||||
t.Fatalf("candidate main=%+v sub=%+v", main, sub)
|
||||
}
|
||||
assertOptionRerollBalances(t, fixture, 800, 13)
|
||||
|
||||
// The candidate is not official equipment until the second-phase confirm.
|
||||
current := fixture.equipment.All()[0]
|
||||
if !reflect.DeepEqual(current.MainOption, fixture.original.MainOption) || !reflect.DeepEqual(current.SubOption, fixture.original.SubOption) {
|
||||
t.Fatalf("reroll changed official equipment before confirm: %+v", current)
|
||||
}
|
||||
|
||||
// Same session and sequence must replay exactly, without charging or rolling again.
|
||||
replayCode, replay, replayHandled, replayErr := fixture.equipment.Handle("/EquipOptionReRoll", request)
|
||||
if replayErr != nil || !replayHandled || replayCode != code || string(replay) != string(response) {
|
||||
t.Fatalf("reroll replay code=%d handled=%v same=%v err=%v", replayCode, replayHandled, string(replay) == string(response), replayErr)
|
||||
}
|
||||
assertOptionRerollBalances(t, fixture, 800, 13)
|
||||
|
||||
_, info, infoHandled, infoErr := fixture.equipment.Handle("/EquipInfo", wire.AppendVarint(nil, 1, 42))
|
||||
if infoErr != nil || !infoHandled {
|
||||
t.Fatalf("EquipInfo handled=%v err=%v", infoHandled, infoErr)
|
||||
}
|
||||
pending, found, err := wire.Bytes(info, 2)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("EquipInfo missing pending option reroll: found=%v err=%v", found, err)
|
||||
}
|
||||
index, pendingMain, pendingSub := equipmentWireOptions(t, pending)
|
||||
if index != fixture.original.InvenIndex || !reflect.DeepEqual(pendingMain, wantMain) || !reflect.DeepEqual(pendingSub, wantSub) {
|
||||
t.Fatalf("EquipInfo pending index=%d main=%+v sub=%+v", index, pendingMain, pendingSub)
|
||||
}
|
||||
|
||||
// Pending candidates are server state, not a transient UI cache. Reopening the
|
||||
// equipment domain must preserve field 2 until the player resolves it.
|
||||
restarted, err := OpenEquipmentInventory(testStore(fixture.equipmentPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restarted.AttachOptionReroll(fixture.design, fixture.wallet, fixture.inventory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restarted.BeginSession("option-reroll-after-restart")
|
||||
_, restartedInfo, _, err := restarted.Handle("/EquipInfo", wire.AppendVarint(nil, 1, 43))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restoredPending, found, err := wire.Bytes(restartedInfo, 2)
|
||||
if err != nil || !found || string(restoredPending) != string(pending) {
|
||||
t.Fatalf("pending did not survive restart: found=%v same=%v err=%v", found, string(restoredPending) == string(pending), err)
|
||||
}
|
||||
|
||||
confirm := optionRerollConfirmRequest(44, fixture.original.InvenIndex, true)
|
||||
confirmCode, confirmResponse, confirmHandled, confirmErr := restarted.Handle("/EquipOptionReRollConfirm", confirm)
|
||||
if confirmErr != nil || !confirmHandled || confirmCode != 193 {
|
||||
t.Fatalf("confirm code=%d handled=%v err=%v", confirmCode, confirmHandled, confirmErr)
|
||||
}
|
||||
confirmedWire, found, err := wire.Bytes(confirmResponse, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("confirm response missing equipment: found=%v err=%v", found, err)
|
||||
}
|
||||
_, confirmedMain, confirmedSub := equipmentWireOptions(t, confirmedWire)
|
||||
if !reflect.DeepEqual(confirmedMain, wantMain) || !reflect.DeepEqual(confirmedSub, wantSub) {
|
||||
t.Fatalf("confirmed main=%+v sub=%+v", confirmedMain, confirmedSub)
|
||||
}
|
||||
confirmed := restarted.All()[0]
|
||||
if !reflect.DeepEqual(confirmed.MainOption, wantMain) || !reflect.DeepEqual(confirmed.SubOption, wantSub) {
|
||||
t.Fatalf("confirmed equipment=%+v", confirmed)
|
||||
}
|
||||
_, resolvedInfo, _, err := restarted.Handle("/EquipInfo", wire.AppendVarint(nil, 1, 45))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, _ := wire.Bytes(resolvedInfo, 2); found {
|
||||
t.Fatal("confirmed candidate remained in EquipInfo field 2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentOptionRerollKeepPreservesOfficialOptions(t *testing.T) {
|
||||
fixture := newEquipmentOptionRerollFixture(t)
|
||||
fixture.equipment.BeginSession("option-reroll-keep")
|
||||
request := fixture.rerollRequest(51, []bool{false, true}, []bool{false, true, false})
|
||||
if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", request); err != nil || !handled || code != 192 {
|
||||
t.Fatalf("reroll code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
assertOptionRerollBalances(t, fixture, 800, 13)
|
||||
|
||||
keep := optionRerollConfirmRequest(52, fixture.original.InvenIndex, false)
|
||||
code, response, handled, err := fixture.equipment.Handle("/EquipOptionReRollConfirm", keep)
|
||||
if err != nil || !handled || code != 193 {
|
||||
t.Fatalf("keep code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
keptWire, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("keep response missing equipment: found=%v err=%v", found, err)
|
||||
}
|
||||
_, keptMain, keptSub := equipmentWireOptions(t, keptWire)
|
||||
if !reflect.DeepEqual(keptMain, fixture.original.MainOption) || !reflect.DeepEqual(keptSub, fixture.original.SubOption) {
|
||||
t.Fatalf("keep response main=%+v sub=%+v", keptMain, keptSub)
|
||||
}
|
||||
current := fixture.equipment.All()[0]
|
||||
if !reflect.DeepEqual(current.MainOption, fixture.original.MainOption) || !reflect.DeepEqual(current.SubOption, fixture.original.SubOption) {
|
||||
t.Fatalf("keep changed official equipment: %+v", current)
|
||||
}
|
||||
_, info, _, err := fixture.equipment.Handle("/EquipInfo", wire.AppendVarint(nil, 1, 53))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, _ := wire.Bytes(info, 2); found {
|
||||
t.Fatal("discarded candidate remained in EquipInfo field 2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentOptionRerollRetryLocksPreviousCandidate(t *testing.T) {
|
||||
fixture := newEquipmentOptionRerollFixture(t)
|
||||
fixture.equipment.BeginSession("option-reroll-retry")
|
||||
|
||||
first := fixture.rerollRequest(54, []bool{false, false}, []bool{false, false, false})
|
||||
if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", first); err != nil || !handled || code != 192 {
|
||||
t.Fatalf("first reroll code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
firstCandidate := fixture.equipment.pendingReroll.Equipment
|
||||
if firstCandidate.MainOption[1].ID == fixture.original.MainOption[1].ID || firstCandidate.SubOption[1].ID == fixture.original.SubOption[1].ID {
|
||||
t.Fatalf("fixture did not change lock targets: first=%+v original=%+v", firstCandidate, fixture.original)
|
||||
}
|
||||
|
||||
// The result screen sends only masks on a retry. Locked values must come
|
||||
// from the first candidate even though it has not been confirmed yet.
|
||||
second := fixture.rerollRequest(55, []bool{false, true}, []bool{false, true, false})
|
||||
if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", second); err != nil || !handled || code != 192 {
|
||||
t.Fatalf("second reroll code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
secondCandidate := fixture.equipment.pendingReroll.Equipment
|
||||
if secondCandidate.MainOption[1] != firstCandidate.MainOption[1] || secondCandidate.SubOption[1] != firstCandidate.SubOption[1] {
|
||||
t.Fatalf("retry lost locked candidate values: first=%+v second=%+v", firstCandidate, secondCandidate)
|
||||
}
|
||||
if secondCandidate.MainOption[1] == fixture.original.MainOption[1] || secondCandidate.SubOption[1] == fixture.original.SubOption[1] {
|
||||
t.Fatalf("retry restored official values instead of candidate: original=%+v second=%+v", fixture.original, secondCandidate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentMainOptionChangeUpdatesPendingCandidate(t *testing.T) {
|
||||
fixture := newEquipmentOptionRerollFixture(t)
|
||||
fixture.equipment.BeginSession("main-option-change")
|
||||
|
||||
first := fixture.rerollRequest(56, []bool{false, false}, []bool{false, false, false})
|
||||
if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", first); err != nil || !handled || code != 192 {
|
||||
t.Fatalf("reroll code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
if got := fixture.equipment.pendingReroll.Equipment.MainOption[0].ID; got != 1 {
|
||||
t.Fatalf("pending first main option=%d want=1 before change", got)
|
||||
}
|
||||
|
||||
change := wire.AppendVarint(nil, 1, 57)
|
||||
change = wire.AppendVarint(change, 2, fixture.original.InvenIndex)
|
||||
change = wire.AppendVarint(change, 3, 100)
|
||||
change = wire.AppendVarint(change, 4, 9)
|
||||
if code, response, handled, err := fixture.equipment.Handle("/EquipMainOptChange", change); err != nil || !handled || code != 537 || len(response) != 0 {
|
||||
t.Fatalf("main option change code=%d response=%x handled=%v err=%v", code, response, handled, err)
|
||||
}
|
||||
if got := fixture.equipment.All()[0].MainOption[0]; got != (EquipmentOption{GroupID: 100, ID: 9}) {
|
||||
t.Fatalf("official first main option=%+v", got)
|
||||
}
|
||||
if got := fixture.equipment.pendingReroll.Equipment.MainOption[0]; got != (EquipmentOption{GroupID: 100, ID: 9}) {
|
||||
t.Fatalf("pending first main option=%+v", got)
|
||||
}
|
||||
|
||||
restarted, err := OpenEquipmentInventory(testStore(fixture.equipmentPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restarted.AttachOptionReroll(fixture.design, fixture.wallet, fixture.inventory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := restarted.All()[0].MainOption[0]; got != (EquipmentOption{GroupID: 100, ID: 9}) {
|
||||
t.Fatalf("restarted official first main option=%+v", got)
|
||||
}
|
||||
if restarted.pendingReroll == nil || restarted.pendingReroll.Equipment.MainOption[0] != (EquipmentOption{GroupID: 100, ID: 9}) {
|
||||
t.Fatalf("restarted pending candidate=%+v", restarted.pendingReroll)
|
||||
}
|
||||
|
||||
confirm := optionRerollConfirmRequest(58, fixture.original.InvenIndex, true)
|
||||
if code, _, handled, err := restarted.Handle("/EquipOptionReRollConfirm", confirm); err != nil || !handled || code != 193 {
|
||||
t.Fatalf("confirm code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
if got := restarted.All()[0].MainOption[0]; got != (EquipmentOption{GroupID: 100, ID: 9}) {
|
||||
t.Fatalf("confirm reverted changed first main option: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentMainOptionChangeRejectsForgedChoice(t *testing.T) {
|
||||
fixture := newEquipmentOptionRerollFixture(t)
|
||||
before := fixture.equipment.All()[0]
|
||||
request := wire.AppendVarint(nil, 1, 59)
|
||||
request = wire.AppendVarint(request, 2, fixture.original.InvenIndex)
|
||||
request = wire.AppendVarint(request, 3, 100)
|
||||
request = wire.AppendVarint(request, 4, 999)
|
||||
if code, _, handled, err := fixture.equipment.Handle("/EquipMainOptChange", request); err == nil || !handled || code != 0 {
|
||||
t.Fatalf("forged main option code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
if after := fixture.equipment.All()[0]; !reflect.DeepEqual(after, before) {
|
||||
t.Fatalf("forged main option changed equipment: before=%+v after=%+v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentOptionRerollRejectsForgedMaterialsWithoutCharge(t *testing.T) {
|
||||
fixture := newEquipmentOptionRerollFixture(t)
|
||||
fixture.equipment.BeginSession("option-reroll-forged-material")
|
||||
|
||||
request := optionRerollRequest(61, fixture.original.InvenIndex,
|
||||
[]bool{false, true}, []bool{false, true, false}, 1,
|
||||
[]Item{{Type: 4, Count: 1}, {InvenIndex: fixture.material.InvenIndex, Type: 8, ID: 17, Count: 1}})
|
||||
if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", request); err == nil || !handled || code != 0 {
|
||||
t.Fatalf("forged materials code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
assertOptionRerollBalances(t, fixture, 1000, 20)
|
||||
_, info, _, err := fixture.equipment.Handle("/EquipInfo", wire.AppendVarint(nil, 1, 62))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, _ := wire.Bytes(info, 2); found {
|
||||
t.Fatal("rejected forged request created a pending candidate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentOptionRerollRejectsInvalidLockMasksWithoutCharge(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
main []bool
|
||||
sub []bool
|
||||
}{
|
||||
{name: "wrong array length", main: []bool{false}, sub: []bool{false, false, false}},
|
||||
{name: "first main slot locked", main: []bool{true, false}, sub: []bool{false, false, false}},
|
||||
{name: "all rerollable slots locked", main: []bool{false, true}, sub: []bool{true, true, true}},
|
||||
}
|
||||
for i, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fixture := newEquipmentOptionRerollFixture(t)
|
||||
fixture.equipment.BeginSession("option-reroll-invalid-lock")
|
||||
locked := countLockedOptions(test.main) + countLockedOptions(test.sub)
|
||||
request := optionRerollRequest(uint64(70+i), fixture.original.InvenIndex, test.main, test.sub, 1,
|
||||
fixture.consumeItems(uint64(locked)))
|
||||
if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", request); err == nil || !handled || code != 0 {
|
||||
t.Fatalf("invalid locks code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
assertOptionRerollBalances(t, fixture, 1000, 20)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentOptionRerollConversionConsumesMixedConcreteStacks(t *testing.T) {
|
||||
fixture := newEquipmentOptionRerollFixture(t)
|
||||
fixture.equipment.BeginSession("option-reroll-conversion")
|
||||
fixture.design.Conversion = &gamedata.EquipmentOptionRerollConversion{
|
||||
Ratio: 10, SourceType: 8, SourceID: 16, TargetType: 8, TargetID: 17,
|
||||
}
|
||||
sources, err := fixture.inventory.GrantOnce("option-reroll-source-material", []gamedata.BattleReward{{Type: 8, ID: 16, Count: 100}})
|
||||
if err != nil || len(sources) != 1 {
|
||||
t.Fatalf("grant conversion source=%+v err=%v", sources, err)
|
||||
}
|
||||
|
||||
// Two locked slots require 7 target units. The client consumes two actual
|
||||
// target units plus 50 source units (10 source == one target).
|
||||
target := fixture.material
|
||||
target.Count = 2
|
||||
source := sources[0]
|
||||
source.Count = 50
|
||||
request := optionRerollRequest(81, fixture.original.InvenIndex,
|
||||
[]bool{false, true}, []bool{false, true, false}, 1,
|
||||
[]Item{{Type: 4, Count: 200}, target, source})
|
||||
if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", request); err != nil || !handled || code != 192 {
|
||||
t.Fatalf("conversion reroll code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
if got := fixture.wallet.Snapshot().Gold; got != 800 {
|
||||
t.Fatalf("conversion gold=%d want=800", got)
|
||||
}
|
||||
if got := optionRerollItemCount(fixture.inventory, 8, 17); got != 18 {
|
||||
t.Fatalf("target material=%d want=18", got)
|
||||
}
|
||||
if got := optionRerollItemCount(fixture.inventory, 8, 16); got != 50 {
|
||||
t.Fatalf("source material=%d want=50", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentOptionRerollConfirmEquippedReturnsCharacter(t *testing.T) {
|
||||
fixture := newEquipmentOptionRerollFixture(t)
|
||||
character := Character{InvenIndex: 535607162, ID: 350, Level: 20}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(filepath.Dir(fixture.equipmentPath), "characters.json")),
|
||||
[]Character{character}, fixture.inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fixture.equipment.AttachCharacters(characters); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.equipment.mu.Lock()
|
||||
next := cloneEquipmentSnapshot(fixture.equipment.owned)
|
||||
next.Equipment[0].UseChar = character.InvenIndex
|
||||
if err := fixture.equipment.commitLocked(next, "option reroll equipped test setup"); err != nil {
|
||||
fixture.equipment.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.equipment.mu.Unlock()
|
||||
fixture.original = fixture.equipment.All()[0]
|
||||
fixture.equipment.BeginSession("option-reroll-equipped")
|
||||
|
||||
request := fixture.rerollRequest(91, []bool{false, true}, []bool{false, true, false})
|
||||
if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", request); err != nil || !handled || code != 192 {
|
||||
t.Fatalf("equipped reroll code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
confirm := optionRerollConfirmRequest(92, fixture.original.InvenIndex, true)
|
||||
code, response, handled, err := fixture.equipment.Handle("/EquipOptionReRollConfirm", confirm)
|
||||
if err != nil || !handled || code != 193 {
|
||||
t.Fatalf("equipped confirm code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
charWire, found, err := wire.Bytes(response, 2)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("confirm response missing CharDBInfo field 2: found=%v err=%v", found, err)
|
||||
}
|
||||
charIndex, found, err := wire.Varint(charWire, 1)
|
||||
if err != nil || !found || charIndex != character.InvenIndex {
|
||||
t.Fatalf("CharDBInfo index=%d want=%d found=%v err=%v", charIndex, character.InvenIndex, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
type equipmentOptionRerollFixture struct {
|
||||
design *gamedata.EquipmentOptionRerollDesign
|
||||
equipment *EquipmentInventory
|
||||
wallet *Wallet
|
||||
inventory *Inventory
|
||||
original Equipment
|
||||
material Item
|
||||
equipmentPath string
|
||||
}
|
||||
|
||||
func newEquipmentOptionRerollFixture(t *testing.T) *equipmentOptionRerollFixture {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
materials, err := inventory.GrantOnce("option-reroll-material", []gamedata.BattleReward{{Type: 8, ID: 17, Count: 20}})
|
||||
if err != nil || len(materials) != 1 {
|
||||
t.Fatalf("grant option material=%+v err=%v", materials, err)
|
||||
}
|
||||
equipmentPath := filepath.Join(dir, "equipment.json")
|
||||
equipment, err := OpenEquipmentInventory(testStore(equipmentPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := optionRerollDomainTestDesign()
|
||||
if err := equipment.AttachOptionReroll(design, wallet, inventory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, err := equipment.GrantOnce("option-reroll-equipment", 77)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipment.mu.Lock()
|
||||
next := cloneEquipmentSnapshot(equipment.owned)
|
||||
next.Equipment[0].Level = 9
|
||||
next.Equipment[0].MainOption = []EquipmentOption{{GroupID: 100, ID: 1}, {GroupID: 101, ID: 2}}
|
||||
next.Equipment[0].SubOption = []EquipmentOption{{GroupID: 200, ID: 10}, {GroupID: 201, ID: 20}, {GroupID: 202, ID: 30}}
|
||||
next.Equipment[0].PrivateOption = &EquipmentOption{GroupID: 300, ID: 40}
|
||||
next.Equipment[0].Rank = []uint64{4, 4, 4}
|
||||
if err := equipment.commitLocked(next, "option reroll test setup"); err != nil {
|
||||
equipment.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipment.mu.Unlock()
|
||||
original := equipment.All()[0]
|
||||
if original.InvenIndex != entry.InvenIndex {
|
||||
t.Fatalf("equipment index=%d want=%d", original.InvenIndex, entry.InvenIndex)
|
||||
}
|
||||
return &equipmentOptionRerollFixture{
|
||||
design: design, equipment: equipment, wallet: wallet, inventory: inventory,
|
||||
original: original, material: materials[0], equipmentPath: equipmentPath,
|
||||
}
|
||||
}
|
||||
|
||||
func optionRerollDomainTestDesign() *gamedata.EquipmentOptionRerollDesign {
|
||||
choices := func(oldID, nextID uint64) []gamedata.WeightedOption {
|
||||
// A zero-weight old value and one positive new value make the domain test
|
||||
// deterministic while retaining two legal choices in every rerollable group.
|
||||
return []gamedata.WeightedOption{{ID: oldID, Weight: 0}, {ID: nextID, Weight: 1}}
|
||||
}
|
||||
return &gamedata.EquipmentOptionRerollDesign{
|
||||
Equipment: map[uint64]gamedata.EquipmentOptionRerollItem{77: {
|
||||
ID: 77, OptionRerollID: 9, PrivateUniqueCharID: 350,
|
||||
MainGroups: []uint64{100, 101}, SubGroups: []uint64{200, 201, 202}, PrivateGroups: []uint64{300},
|
||||
}},
|
||||
Costs: map[uint64]gamedata.EquipmentOptionRerollCost{9: {Resources: []gamedata.EquipmentOptionRerollResource{
|
||||
{Type: 4, BaseCount: 100, LockCount: 50},
|
||||
{Type: 8, ID: 17, BaseCount: 3, LockCount: 2},
|
||||
}}},
|
||||
Groups: map[uint64]gamedata.OptionGroup{
|
||||
100: {ID: 100, Choices: choices(1, 9)},
|
||||
101: {ID: 101, Choices: choices(2, 3)},
|
||||
200: {ID: 200, Choices: choices(10, 11)},
|
||||
201: {ID: 201, Choices: choices(20, 21)},
|
||||
202: {ID: 202, Choices: choices(30, 31)},
|
||||
300: {ID: 300, Choices: choices(40, 41)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *equipmentOptionRerollFixture) consumeItems(locked uint64) []Item {
|
||||
material := f.material
|
||||
material.Count = 3 + 2*locked
|
||||
return []Item{{Type: 4, Count: 100 + 50*locked}, material}
|
||||
}
|
||||
|
||||
func (f *equipmentOptionRerollFixture) rerollRequest(seq uint64, main, sub []bool) []byte {
|
||||
locked := countLockedOptions(main) + countLockedOptions(sub)
|
||||
return optionRerollRequest(seq, f.original.InvenIndex, main, sub, 1, f.consumeItems(uint64(locked)))
|
||||
}
|
||||
|
||||
func optionRerollRequest(seq, equipmentIndex uint64, main, sub []bool, rerollType uint64, consume []Item) []byte {
|
||||
request := wire.AppendVarint(nil, 1, seq)
|
||||
request = wire.AppendVarint(request, 2, equipmentIndex)
|
||||
request = appendPackedOptionLocks(request, 3, main)
|
||||
request = appendPackedOptionLocks(request, 4, sub)
|
||||
for _, item := range consume {
|
||||
request = wire.AppendBytes(request, 5, ItemWire(item))
|
||||
}
|
||||
if rerollType != 0 {
|
||||
request = wire.AppendVarint(request, 6, rerollType)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func optionRerollConfirmRequest(seq, equipmentIndex uint64, confirm bool) []byte {
|
||||
request := wire.AppendVarint(nil, 1, seq)
|
||||
request = wire.AppendVarint(request, 2, equipmentIndex)
|
||||
if confirm {
|
||||
request = wire.AppendVarint(request, 3, 1)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func appendPackedOptionLocks(dst []byte, field int, locks []bool) []byte {
|
||||
packed := make([]byte, len(locks))
|
||||
for i, locked := range locks {
|
||||
if locked {
|
||||
packed[i] = 1
|
||||
}
|
||||
}
|
||||
return wire.AppendBytes(dst, field, packed)
|
||||
}
|
||||
|
||||
func countLockedOptions(locks []bool) int {
|
||||
count := 0
|
||||
for _, locked := range locks {
|
||||
if locked {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func optionRerollResponseOptions(t *testing.T, response []byte) ([]EquipmentOption, []EquipmentOption) {
|
||||
t.Helper()
|
||||
var main, sub []EquipmentOption
|
||||
if err := wire.Walk(response, func(field wire.Field) error {
|
||||
if field.Type != 2 || (field.Number != 1 && field.Number != 2) {
|
||||
return nil
|
||||
}
|
||||
option := decodeOptionRerollOption(t, field.Value)
|
||||
if field.Number == 1 {
|
||||
main = append(main, option)
|
||||
} else {
|
||||
sub = append(sub, option)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return main, sub
|
||||
}
|
||||
|
||||
func equipmentWireOptions(t *testing.T, equipment []byte) (uint64, []EquipmentOption, []EquipmentOption) {
|
||||
t.Helper()
|
||||
index, found, err := wire.Varint(equipment, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("equipment wire missing index: found=%v err=%v", found, err)
|
||||
}
|
||||
base, found, err := wire.Bytes(equipment, 5)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("equipment wire missing base info: found=%v err=%v", found, err)
|
||||
}
|
||||
var main, sub []EquipmentOption
|
||||
if err := wire.Walk(base, func(field wire.Field) error {
|
||||
if field.Type != 2 || (field.Number != 3 && field.Number != 4) {
|
||||
return nil
|
||||
}
|
||||
option := decodeOptionRerollOption(t, field.Value)
|
||||
if field.Number == 3 {
|
||||
main = append(main, option)
|
||||
} else {
|
||||
sub = append(sub, option)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return index, main, sub
|
||||
}
|
||||
|
||||
func decodeOptionRerollOption(t *testing.T, data []byte) EquipmentOption {
|
||||
t.Helper()
|
||||
group, groupFound, err := wire.Varint(data, 1)
|
||||
if err != nil || !groupFound {
|
||||
t.Fatalf("option missing group: found=%v err=%v", groupFound, err)
|
||||
}
|
||||
id, idFound, err := wire.Varint(data, 2)
|
||||
if err != nil || !idFound {
|
||||
t.Fatalf("option missing id: found=%v err=%v", idFound, err)
|
||||
}
|
||||
return EquipmentOption{GroupID: group, ID: id}
|
||||
}
|
||||
|
||||
func assertOptionRerollBalances(t *testing.T, fixture *equipmentOptionRerollFixture, gold, material uint64) {
|
||||
t.Helper()
|
||||
if got := fixture.wallet.Snapshot().Gold; got != gold {
|
||||
t.Fatalf("gold=%d want=%d", got, gold)
|
||||
}
|
||||
got := optionRerollItemCount(fixture.inventory, 8, 17)
|
||||
if got != material {
|
||||
t.Fatalf("option material=%d want=%d", got, material)
|
||||
}
|
||||
}
|
||||
|
||||
func optionRerollItemCount(inventory *Inventory, itemType, id uint64) uint64 {
|
||||
var count uint64
|
||||
for _, item := range inventory.All() {
|
||||
if item.Type == itemType && item.ID == id {
|
||||
count += item.Count
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestEquipmentPresetSaveInfoRenameAndRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
character := Character{InvenIndex: 100, ID: 350, Level: 20}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{character}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipment, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := equipment.AttachSlots(map[uint64]uint64{10: 0, 11: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := equipment.AttachCharacters(characters); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, _ := equipment.GrantOnce("preset-first", 10)
|
||||
second, _ := equipment.GrantOnce("preset-second", 11)
|
||||
equipment.mu.Lock()
|
||||
next := cloneEquipmentSnapshot(equipment.owned)
|
||||
next.Equipment[0].UseChar = character.InvenIndex
|
||||
next.Equipment[1].UseChar = character.InvenIndex
|
||||
if err := equipment.commitLocked(next, "preset test setup"); err != nil {
|
||||
equipment.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipment.mu.Unlock()
|
||||
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
request = wire.AppendVarint(request, 2, character.InvenIndex)
|
||||
request = wire.AppendVarint(request, 3, 2)
|
||||
request = wire.AppendString(request, 4, "Boss")
|
||||
request = wire.AppendVarint(request, 5, 7)
|
||||
request = wire.AppendVarint(request, 6, 3)
|
||||
for slot, index := range []uint64{first.InvenIndex, second.InvenIndex, 0, 0, 0} {
|
||||
item := wire.AppendVarint(nil, 1, uint64(slot))
|
||||
if index != 0 {
|
||||
item = wire.AppendVarint(item, 2, index)
|
||||
}
|
||||
request = wire.AppendBytes(request, 7, item)
|
||||
}
|
||||
if code, _, handled, err := equipment.Handle("/EquipPresetSave", request); err != nil || !handled || code != 254 {
|
||||
t.Fatalf("preset save code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
assertEquipmentPresetInfo(t, equipment, character.InvenIndex, 2, "Boss", 7, 3, []uint64{first.InvenIndex, second.InvenIndex, 0, 0, 0})
|
||||
|
||||
rename := wire.AppendVarint(nil, 1, 2)
|
||||
rename = wire.AppendVarint(rename, 2, character.InvenIndex)
|
||||
rename = wire.AppendVarint(rename, 3, 2)
|
||||
rename = wire.AppendString(rename, 4, "Raid")
|
||||
rename = wire.AppendVarint(rename, 5, 9)
|
||||
rename = wire.AppendVarint(rename, 6, 4)
|
||||
if code, _, handled, err := equipment.Handle("/EquipPresetNameChange", rename); err != nil || !handled || code != 259 {
|
||||
t.Fatalf("preset rename code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
assertEquipmentPresetInfo(t, equipment, character.InvenIndex, 2, "Raid", 9, 4, []uint64{first.InvenIndex, second.InvenIndex, 0, 0, 0})
|
||||
|
||||
restarted, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restarted.AttachCharacters(characters); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertEquipmentPresetInfo(t, restarted, character.InvenIndex, 2, "Raid", 9, 4, []uint64{first.InvenIndex, second.InvenIndex, 0, 0, 0})
|
||||
}
|
||||
|
||||
func TestEquipmentPresetRejectsForgedEquipment(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
characters, _ := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 100, ID: 350, Level: 20}, {InvenIndex: 200, ID: 351, Level: 20}}, inventory, "", "")
|
||||
equipment, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
_ = equipment.AttachSlots(map[uint64]uint64{10: 0})
|
||||
_ = equipment.AttachCharacters(characters)
|
||||
item, _ := equipment.GrantOnce("preset-forged", 10)
|
||||
equipment.mu.Lock()
|
||||
next := cloneEquipmentSnapshot(equipment.owned)
|
||||
next.Equipment[0].UseChar = 200
|
||||
_ = equipment.commitLocked(next, "preset forged setup")
|
||||
equipment.mu.Unlock()
|
||||
request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 100), 3, 1)
|
||||
request = wire.AppendString(request, 4, "Forged")
|
||||
request = wire.AppendVarint(request, 5, 1)
|
||||
for slot := 0; slot < equipmentSlotCount; slot++ {
|
||||
entry := wire.AppendVarint(nil, 1, uint64(slot))
|
||||
if slot == 1 {
|
||||
entry = wire.AppendVarint(entry, 2, item.InvenIndex)
|
||||
}
|
||||
request = wire.AppendBytes(request, 7, entry)
|
||||
}
|
||||
if _, _, handled, err := equipment.Handle("/EquipPresetSave", request); err == nil || !handled {
|
||||
t.Fatalf("forged preset handled=%v err=%v", handled, err)
|
||||
}
|
||||
if len(equipment.presets) != 0 {
|
||||
t.Fatal("forged preset was persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentPresetNameChangeCreatesEmptySlot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
characters, _ := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 100, ID: 350, Level: 20}}, inventory, "", "")
|
||||
equipment, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
_ = equipment.AttachCharacters(characters)
|
||||
request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 100), 3, 5)
|
||||
request = wire.AppendString(request, 4, "Empty")
|
||||
request = wire.AppendVarint(request, 5, 1)
|
||||
if code, _, handled, err := equipment.Handle("/EquipPresetNameChange", request); err != nil || !handled || code != 259 {
|
||||
t.Fatalf("empty preset rename code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
assertEquipmentPresetInfo(t, equipment, 100, 5, "Empty", 1, 0, []uint64{0, 0, 0, 0, 0})
|
||||
}
|
||||
|
||||
func assertEquipmentPresetInfo(t *testing.T, inventory *EquipmentInventory, character, slot uint64, name string, resource, color uint64, wantItems []uint64) {
|
||||
t.Helper()
|
||||
code, response, handled, err := inventory.Handle("/EquipPresetInfo", wire.AppendVarint(nil, 1, 99))
|
||||
if err != nil || !handled || code != 253 {
|
||||
t.Fatalf("preset info code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
charInfo, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("preset char info found=%v err=%v", found, err)
|
||||
}
|
||||
if got, _, _ := wire.Varint(charInfo, 1); got != character {
|
||||
t.Fatalf("preset character=%d want=%d", got, character)
|
||||
}
|
||||
preset, found, err := wire.Bytes(charInfo, 2)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("preset found=%v err=%v", found, err)
|
||||
}
|
||||
if got, _, _ := wire.Varint(preset, 2); got != slot {
|
||||
t.Fatalf("preset slot=%d want=%d", got, slot)
|
||||
}
|
||||
if got, _, _ := wire.Bytes(preset, 1); string(got) != name {
|
||||
t.Fatalf("preset name=%q want=%q", got, name)
|
||||
}
|
||||
if got, _, _ := wire.Varint(preset, 3); got != resource {
|
||||
t.Fatalf("preset resource=%d want=%d", got, resource)
|
||||
}
|
||||
if got, _, _ := wire.Varint(preset, 4); got != color {
|
||||
t.Fatalf("preset color=%d want=%d", got, color)
|
||||
}
|
||||
var gotItems []uint64
|
||||
if err := wire.Walk(preset, func(field wire.Field) error {
|
||||
if field.Number == 5 && field.Type == 2 {
|
||||
index, _, err := wire.Varint(field.Value, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gotItems = append(gotItems, index)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotItems, wantItems) {
|
||||
t.Fatalf("preset items=%v want=%v", gotItems, wantItems)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestEquipmentUpgradeAndSequenceUseGameDataCosts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := &gamedata.EquipmentUpgradeDesign{
|
||||
MaxLevel: map[uint64]uint64{943035: 3}, Group: map[uint64]uint64{943035: 954},
|
||||
RankGroup: map[uint64]uint64{943035: 904}, RankRatio: map[[2]uint64][]float64{{904, 1}: {1, 0, 0, 0}},
|
||||
Levels: map[[2]uint64]gamedata.EquipmentUpgradeLevel{
|
||||
{954, 0}: {Level: 0, Costs: []gamedata.PromotionCost{{Type: 4, Count: 100}}, SuccessRatio: 1},
|
||||
{954, 1}: {Level: 1, Costs: []gamedata.PromotionCost{{Type: 4, Count: 200}}, SuccessRatio: 1},
|
||||
{954, 2}: {Level: 2, Costs: []gamedata.PromotionCost{{Type: 4, Count: 300}}, SuccessRatio: 1},
|
||||
},
|
||||
}
|
||||
items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AttachUpgrade(design, wallet, items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, err := store.GrantOnce("upgrade", 943035)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, entry.InvenIndex)
|
||||
request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 100}))
|
||||
code, response, handled, err := store.Handle("/EquipUpgrade", request)
|
||||
if err != nil || !handled || code != 37 {
|
||||
t.Fatalf("single upgrade code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
encoded, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("single upgrade equipment: %v", err)
|
||||
}
|
||||
base, _, _ := wire.Bytes(encoded, 5)
|
||||
if level, _, _ := wire.Varint(base, 2); level != 1 {
|
||||
t.Fatalf("single upgraded level=%d", level)
|
||||
}
|
||||
sequence := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, entry.InvenIndex)
|
||||
sequence = wire.AppendVarint(sequence, 3, 9)
|
||||
sequence = wire.AppendVarint(sequence, 6, 3)
|
||||
code, response, handled, err = store.Handle("/EquipSequenceUpgrade", sequence)
|
||||
if err != nil || !handled || code != 176 {
|
||||
t.Fatalf("sequence upgrade code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
if result, _, _ := wire.Varint(response, 3); result != equipUpgradeStopMaxLevel {
|
||||
t.Fatalf("sequence result=%d", result)
|
||||
}
|
||||
if attempts, _, _ := wire.Varint(response, 4); attempts != 2 {
|
||||
t.Fatalf("sequence attempts=%d", attempts)
|
||||
}
|
||||
if used, _, _ := wire.Varint(response, 7); used != 500 {
|
||||
t.Fatalf("sequence used gold=%d", used)
|
||||
}
|
||||
if wallet.Snapshot().Gold != 400 {
|
||||
t.Fatalf("wallet gold=%d", wallet.Snapshot().Gold)
|
||||
}
|
||||
restored, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := restored.All(); len(got) != 1 || got[0].Level != 3 || got[0].UpgradeAttempts != 3 {
|
||||
t.Fatalf("persisted upgrade=%+v", got)
|
||||
}
|
||||
if rank := restored.All()[0].Rank; len(rank) != 3 || rank[0] != 1 || rank[1] != 0 || rank[2] != 0 {
|
||||
t.Fatalf("+3 should roll only the first rank: %v", rank)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentSmeltingImprovesByTotalScoreAndReplaysWithoutSecondCharge(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000, EquipMileageExchangeGage: 990})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
materials, err := inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 100}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := smeltingTestDesign([][]float64{{1, 0, 0, 0}, {0, 0, 0, 1}, {0, 0, 0, 1}})
|
||||
if err := store.AttachSmelting(design, wallet, inventory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.BeginSession("smelting-test")
|
||||
entry, err := store.GrantOnce("refinable", 943035)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.owned.Equipment[0].Level = 9
|
||||
store.owned.Equipment[0].Rank = []uint64{4, 1, 1} // 6 -> candidate 1+4+4=9.
|
||||
if err := store.commitLocked(cloneEquipmentSnapshot(store.owned), "test setup"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 77), 2, entry.InvenIndex)
|
||||
request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 80}))
|
||||
material := materials[0]
|
||||
material.Count = 30
|
||||
request = wire.AppendBytes(request, 3, ItemWire(material))
|
||||
code, response, handled, err := store.Handle("/EquipSmelting", request)
|
||||
if err != nil || !handled || code != 105 {
|
||||
t.Fatalf("smelting code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
if result, found, _ := wire.Varint(response, 3); found || result != 0 {
|
||||
t.Fatalf("successful smelting result=%d found=%v", result, found)
|
||||
}
|
||||
if got := store.All()[0].Rank; len(got) != 3 || got[0] != 1 || got[1] != 4 || got[2] != 4 {
|
||||
t.Fatalf("smelting rank=%v", got)
|
||||
}
|
||||
if gauge, _, _ := wire.Varint(response, 5); gauge != 20 {
|
||||
t.Fatalf("smelting gauge=%d", gauge)
|
||||
}
|
||||
bundle, found, err := wire.Bytes(response, 6)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("smelting mileage bundle: %v", err)
|
||||
}
|
||||
reward, found, err := wire.Bytes(bundle, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("smelting mileage item: %v", err)
|
||||
}
|
||||
if typ, _, _ := wire.Varint(reward, 3); typ != 68 {
|
||||
t.Fatalf("smelting mileage type=%d", typ)
|
||||
}
|
||||
if count, _, _ := wire.Varint(reward, 4); count != 1 {
|
||||
t.Fatalf("smelting mileage count=%d", count)
|
||||
}
|
||||
if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileage != 1 || currency.EquipMileageExchangeGage != 20 {
|
||||
t.Fatalf("smelting wallet=%+v", currency)
|
||||
}
|
||||
if _, replay, _, err := store.Handle("/EquipSmelting", request); err != nil || string(replay) != string(response) {
|
||||
t.Fatalf("smelting replay differs err=%v", err)
|
||||
}
|
||||
if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileage != 1 || currency.EquipMileageExchangeGage != 20 {
|
||||
t.Fatalf("smelting replay charged again: %+v", currency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentSmeltingFailureConsumesAndReturnsCandidateGrades(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wallet, _ := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000})
|
||||
inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
materials, _ := inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 30}})
|
||||
store, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
if err := store.AttachSmelting(smeltingTestDesign([][]float64{{1, 0, 0, 0}, {1, 0, 0, 0}, {1, 0, 0, 0}}), wallet, inventory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, _ := store.GrantOnce("refinable", 943035)
|
||||
store.owned.Equipment[0].Level = 9
|
||||
store.owned.Equipment[0].Rank = []uint64{4, 4, 4}
|
||||
if err := store.commitLocked(cloneEquipmentSnapshot(store.owned), "test setup"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, entry.InvenIndex)
|
||||
request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 80}))
|
||||
request = wire.AppendBytes(request, 3, ItemWire(materials[0]))
|
||||
_, response, _, err := store.Handle("/EquipSmelting", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result, _, _ := wire.Varint(response, 3); result != equipUpgradeFail {
|
||||
t.Fatalf("smelting failure result=%d", result)
|
||||
}
|
||||
var grades []uint64
|
||||
if err := wire.Walk(response, func(field wire.Field) error {
|
||||
if field.Number == 4 {
|
||||
value, _ := binary.Uvarint(field.Value)
|
||||
grades = append(grades, value)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(grades) != 3 || grades[0] != 1 || grades[1] != 1 || grades[2] != 1 {
|
||||
t.Fatalf("failure candidate grades=%v", grades)
|
||||
}
|
||||
if got := store.All()[0].Rank; got[0] != 4 || got[1] != 4 || got[2] != 4 {
|
||||
t.Fatalf("failure overwrote ranks=%v", got)
|
||||
}
|
||||
if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileageExchangeGage != 30 {
|
||||
t.Fatalf("failure did not consume: %+v", currency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentSequenceSmeltingRepeatsAndStopsAtTargetScore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wallet, _ := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000})
|
||||
inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
_, _ = inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 300}})
|
||||
store, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
if err := store.AttachSmelting(smeltingTestDesign([][]float64{{1, 0, 0, 0}, {0, 0, 0, 1}, {0, 0, 0, 1}}), wallet, inventory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, _ := store.GrantOnce("refinable", 943035)
|
||||
store.owned.Equipment[0].Level = 9
|
||||
store.owned.Equipment[0].Rank = []uint64{4, 1, 1}
|
||||
if err := store.commitLocked(cloneEquipmentSnapshot(store.owned), "test setup"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, entry.InvenIndex)
|
||||
request = wire.AppendVarint(request, 3, 100)
|
||||
request = wire.AppendVarint(request, 4, 9)
|
||||
code, response, handled, err := store.Handle("/EquipSequenceSmelting", request)
|
||||
if err != nil || !handled || code != 177 {
|
||||
t.Fatalf("sequence smelting code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
if result, _, _ := wire.Varint(response, 3); result != equipUpgradeStopTargetLevel {
|
||||
t.Fatalf("sequence smelting result=%d", result)
|
||||
}
|
||||
if attempts, _, _ := wire.Varint(response, 4); attempts != 1 {
|
||||
t.Fatalf("sequence smelting attempts=%d", attempts)
|
||||
}
|
||||
if successes, _, _ := wire.Varint(response, 9); successes != 1 {
|
||||
t.Fatalf("sequence smelting successes=%d", successes)
|
||||
}
|
||||
if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileageExchangeGage != 30 {
|
||||
t.Fatalf("sequence smelting wallet=%+v", currency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentSequenceSmeltingPacketLimitIsNotTerminal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wallet, _ := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000})
|
||||
inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
_, _ = inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 300}})
|
||||
store, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
if err := store.AttachSmelting(smeltingTestDesign([][]float64{{1, 0, 0, 0}, {1, 0, 0, 0}, {1, 0, 0, 0}}), wallet, inventory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, _ := store.GrantOnce("refinable", 943035)
|
||||
store.owned.Equipment[0].Level = 9
|
||||
store.owned.Equipment[0].Rank = []uint64{1, 1, 1}
|
||||
if err := store.commitLocked(cloneEquipmentSnapshot(store.owned), "test setup"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, entry.InvenIndex)
|
||||
request = wire.AppendVarint(request, 3, 2)
|
||||
request = wire.AppendVarint(request, 4, 12)
|
||||
_, response, _, err := store.Handle("/EquipSequenceSmelting", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result, _, _ := wire.Varint(response, 3); result != equipUpgradeSuccess {
|
||||
t.Fatalf("packet exhaustion result=%d want non-terminal success", result)
|
||||
}
|
||||
if attempts, _, _ := wire.Varint(response, 4); attempts != 2 {
|
||||
t.Fatalf("sequence smelting attempts=%d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func smeltingTestDesign(ratios [][]float64) *gamedata.EquipmentSmeltingDesign {
|
||||
design := &gamedata.EquipmentSmeltingDesign{
|
||||
Equipment: map[uint64]gamedata.EquipmentSmeltingItem{943035: {Grade: 4, RankGroup: 904, MaxLevel: 9}},
|
||||
Ranks: map[[2]uint64]gamedata.EquipmentSmeltingRank{},
|
||||
Grades: map[uint64][]gamedata.PromotionCost{4: {{Type: 4, Count: 80}, {Type: 8, ID: 10, Count: 30}}},
|
||||
Mileage: gamedata.EquipmentSmeltingMileage{UseType: 8, UseID: 10, UseCount: 1000, RewardType: 68, RewardCount: 1},
|
||||
MaxStreak: 5000,
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
design.Ranks[[2]uint64{904, uint64(i + 1)}] = gamedata.EquipmentSmeltingRank{Values: []uint64{1, 2, 3, 4}, GrowthPoint: []uint64{1, 2, 3, 4}, Ratio: ratios[i]}
|
||||
}
|
||||
return design
|
||||
}
|
||||
|
||||
func TestEquipmentUpgradeFailureConsumesGoldWithoutLevel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 200})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := &gamedata.EquipmentUpgradeDesign{MaxLevel: map[uint64]uint64{1: 1}, Group: map[uint64]uint64{1: 2}, Levels: map[[2]uint64]gamedata.EquipmentUpgradeLevel{{2, 0}: {Costs: []gamedata.PromotionCost{{Type: 4, Count: 50}}, SuccessRatio: 0}}}
|
||||
items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AttachUpgrade(design, wallet, items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, err := store.GrantOnce("failure", 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, entry.InvenIndex)
|
||||
request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 50}))
|
||||
_, response, _, err := store.Handle("/EquipUpgrade", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result, _, _ := wire.Varint(response, 2); result != equipUpgradeFail {
|
||||
t.Fatalf("failure result=%d", result)
|
||||
}
|
||||
if got := store.All()[0]; got.Level != 0 || got.UpgradeAttempts != 1 {
|
||||
t.Fatalf("failed upgrade state=%+v", got)
|
||||
}
|
||||
if wallet.Snapshot().Gold != 150 {
|
||||
t.Fatalf("failure gold=%d", wallet.Snapshot().Gold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentUpgradeConsumesResourceStacksAndSequenceStopsWhenEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resources, err := inventory.GrantOnce("upgrade-items", []gamedata.BattleReward{{Type: 8, ID: 3001, Count: 2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := &gamedata.EquipmentUpgradeDesign{
|
||||
MaxLevel: map[uint64]uint64{10: 3}, Group: map[uint64]uint64{10: 20},
|
||||
RankGroup: map[uint64]uint64{10: 30}, RankRatio: map[[2]uint64][]float64{{30, 1}: {1, 0, 0, 0}},
|
||||
Levels: map[[2]uint64]gamedata.EquipmentUpgradeLevel{
|
||||
{20, 0}: {Costs: []gamedata.PromotionCost{{Type: 8, ID: 3001, Count: 1}}, SuccessRatio: 1},
|
||||
{20, 1}: {Costs: []gamedata.PromotionCost{{Type: 8, ID: 3001, Count: 1}}, SuccessRatio: 1},
|
||||
{20, 2}: {Costs: []gamedata.PromotionCost{{Type: 8, ID: 3001, Count: 1}}, SuccessRatio: 1},
|
||||
},
|
||||
}
|
||||
if err := store.AttachUpgrade(design, wallet, inventory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipment, err := store.GrantOnce("resource-upgrade", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := resources[0]
|
||||
first.Count = 1
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, equipment.InvenIndex)
|
||||
request = wire.AppendBytes(request, 3, ItemWire(first))
|
||||
if _, _, _, err := store.Handle("/EquipUpgrade", request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sequence := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, equipment.InvenIndex)
|
||||
sequence = wire.AppendVarint(sequence, 3, 10)
|
||||
sequence = wire.AppendVarint(sequence, 6, 3)
|
||||
_, response, _, err := store.Handle("/EquipSequenceUpgrade", sequence)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result, _, _ := wire.Varint(response, 3); result != equipUpgradeStopNotEnough {
|
||||
t.Fatalf("resource sequence result=%d", result)
|
||||
}
|
||||
var lack []byte
|
||||
if err := wire.Walk(response, func(field wire.Field) error {
|
||||
if field.Number == 6 {
|
||||
lack = field.Value
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if itemType, _, _ := wire.Varint(lack, 3); itemType != 8 {
|
||||
t.Fatalf("missing resource type=%d", itemType)
|
||||
}
|
||||
if attempts, _, _ := wire.Varint(response, 4); attempts != 1 {
|
||||
t.Fatalf("resource sequence attempts=%d", attempts)
|
||||
}
|
||||
if got := store.All()[0]; got.Level != 2 {
|
||||
t.Fatalf("resource upgraded level=%d", got.Level)
|
||||
}
|
||||
if err := inventory.CanConsume([]Item{first}); err == nil {
|
||||
t.Fatal("resource stack was not exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentCustomMarkSetDeletePersistsAndReturnsInInfo(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "equipment.json")
|
||||
store, err := OpenEquipmentInventory(testStore(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, err := store.GrantOnce("marked", 943035)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, entry.InvenIndex)
|
||||
set = wire.AppendBytes(set, 3, []byte("~15|5"))
|
||||
code, response, handled, err := store.Handle("/EquipMarkSet", set)
|
||||
if err != nil || !handled || code != 396 || len(response) != 0 {
|
||||
t.Fatalf("mark set code=%d handled=%v response=%x err=%v", code, handled, response, err)
|
||||
}
|
||||
restored, err := OpenEquipmentInventory(testStore(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
infoRequest := wire.AppendVarint(nil, 1, 2)
|
||||
_, info, _, err := restored.Handle("/EquipInfo", infoRequest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipment, found, err := wire.Bytes(info, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("marked equipment missing: %v", err)
|
||||
}
|
||||
mark, found, err := wire.Bytes(equipment, 8)
|
||||
if err != nil || !found || string(mark) != "~15|5" {
|
||||
t.Fatalf("mark=%q found=%v err=%v", mark, found, err)
|
||||
}
|
||||
deleteRequest := wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, entry.InvenIndex)
|
||||
code, response, handled, err = restored.Handle("/EquipMarkDelete", deleteRequest)
|
||||
if err != nil || !handled || code != 397 || len(response) != 0 {
|
||||
t.Fatalf("mark delete code=%d handled=%v response=%x err=%v", code, handled, response, err)
|
||||
}
|
||||
cleared, err := OpenEquipmentInventory(testStore(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cleared.All(); len(got) != 1 || got[0].Mark != "" {
|
||||
t.Fatalf("cleared equipment=%+v", got)
|
||||
}
|
||||
for _, invalid := range []string{"", "~0|0", "~16|0", "~1|6", "abc|0", "x", "_|0"} {
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, entry.InvenIndex)
|
||||
if invalid != "" {
|
||||
request = wire.AppendBytes(request, 3, []byte(invalid))
|
||||
}
|
||||
if _, _, _, err := cleared.Handle("/EquipMarkSet", request); err == nil {
|
||||
t.Fatalf("invalid mark %q accepted", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentLockAndProtoDefaultUnlockPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "equipment.json")
|
||||
store, err := OpenEquipmentInventory(testStore(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, err := store.GrantOnce("lockable", 943035)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lock := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, entry.InvenIndex), 3, 1)
|
||||
code, response, handled, err := store.Handle("/EquipLock", lock)
|
||||
if err != nil || !handled || code != 38 || len(response) != 0 {
|
||||
t.Fatalf("lock code=%d handled=%v response=%x err=%v", code, handled, response, err)
|
||||
}
|
||||
locked, err := OpenEquipmentInventory(testStore(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := locked.All(); len(got) != 1 || got[0].LockFlag != 1 {
|
||||
t.Fatalf("locked equipment=%+v", got)
|
||||
}
|
||||
unlock := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, entry.InvenIndex)
|
||||
code, response, handled, err = locked.Handle("/EquipLock", unlock)
|
||||
if err != nil || !handled || code != 38 || len(response) != 0 {
|
||||
t.Fatalf("unlock code=%d handled=%v response=%x err=%v", code, handled, response, err)
|
||||
}
|
||||
unlocked, err := OpenEquipmentInventory(testStore(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := unlocked.All(); len(got) != 1 || got[0].LockFlag != 0 {
|
||||
t.Fatalf("unlocked equipment=%+v", got)
|
||||
}
|
||||
invalid := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, entry.InvenIndex), 3, 2)
|
||||
if _, _, _, err := unlocked.Handle("/EquipLock", invalid); err == nil {
|
||||
t.Fatal("invalid lock flag accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentGrantPersistsAndIsIdempotent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "equipment.json")
|
||||
store, err := OpenEquipmentInventory(testStore(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := store.GrantOnce("pack21:quest28:equip10010", 10010)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := store.GrantOnce("pack21:quest28:equip10010", 10010)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.InvenIndex != again.InvenIndex || first.ID != again.ID || first.InvenIndex == 0 || first.ID != 10010 {
|
||||
t.Fatalf("grants: %+v %+v", first, again)
|
||||
}
|
||||
restored, err := OpenEquipmentInventory(testStore(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
code, response, ok, err := restored.Handle("/EquipInfo", request)
|
||||
if err != nil || !ok || code != 34 {
|
||||
t.Fatalf("handle: %d %v %v", code, ok, err)
|
||||
}
|
||||
encoded, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("equipment missing: %v", err)
|
||||
}
|
||||
index, _, _ := wire.Varint(encoded, 1)
|
||||
base, _, _ := wire.Bytes(encoded, 5)
|
||||
id, _, _ := wire.Varint(base, 1)
|
||||
if index != first.InvenIndex || id != 10010 {
|
||||
t.Fatalf("wire index=%d id=%d", index, id)
|
||||
}
|
||||
var ranks []uint64
|
||||
if err := wire.Walk(base, func(field wire.Field) error {
|
||||
if field.Number == 6 {
|
||||
value, _ := binary.Uvarint(field.Value)
|
||||
ranks = append(ranks, value)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ranks) != 3 || ranks[0] != 0 || ranks[1] != 0 || ranks[2] != 0 {
|
||||
t.Fatalf("initial rank slots=%v, want three explicit zeroes", ranks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentUsePersistsCharacterBinding(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
starter := &Starter{Version: "2.35.10"}
|
||||
items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")),
|
||||
[]Character{{InvenIndex: 535607162, ID: 350, Level: 20}}, items, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipment, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := equipment.AttachCharacters(characters); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, err := equipment.GrantOnce("quest28", 10010)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
request = wire.AppendVarint(request, 2, entry.InvenIndex)
|
||||
request = wire.AppendVarint(request, 3, 535607162)
|
||||
code, response, ok, err := equipment.Handle("/EquipUse", request)
|
||||
if err != nil || !ok || code != 35 {
|
||||
t.Fatalf("use: code=%d ok=%v err=%v", code, ok, err)
|
||||
}
|
||||
character, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("response character: %v", err)
|
||||
}
|
||||
index, _, _ := wire.Varint(character, 1)
|
||||
if index != 535607162 {
|
||||
t.Fatalf("character index=%d", index)
|
||||
}
|
||||
restored, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
infoRequest := wire.AppendVarint(nil, 1, 2)
|
||||
_, info, _, err := restored.Handle("/EquipInfo", infoRequest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, found, err := wire.Bytes(info, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("restored equipment: %v", err)
|
||||
}
|
||||
useChar, found, err := wire.Varint(encoded, 2)
|
||||
if err != nil || !found || useChar != 535607162 {
|
||||
t.Fatalf("useChar=%d found=%v err=%v", useChar, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentClearPersistsUnboundEquipmentAndReturnsCharacter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")),
|
||||
[]Character{{InvenIndex: 535607162, ID: 350, Level: 20}}, items, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "equipment.json")
|
||||
equipment, err := OpenEquipmentInventory(testStore(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := equipment.AttachCharacters(characters); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, err := equipment.GrantOnce("equipped", 943035)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipment.owned.Equipment[0].UseChar = 535607162
|
||||
equipment.owned.Equipment[0].LockFlag = 1 // Lock prevents disposal, not unequipping.
|
||||
if err := equipment.commitLocked(cloneEquipmentSnapshot(equipment.owned), "test setup"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 9), 2, entry.InvenIndex)
|
||||
request = wire.AppendVarint(request, 3, 535607162)
|
||||
code, response, handled, err := equipment.Handle("/EquipClear", request)
|
||||
if err != nil || !handled || code != 36 {
|
||||
t.Fatalf("clear code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
character, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing clear character: %v", err)
|
||||
}
|
||||
if index, _, _ := wire.Varint(character, 1); index != 535607162 {
|
||||
t.Fatalf("clear character index=%d", index)
|
||||
}
|
||||
restored, err := OpenEquipmentInventory(testStore(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := restored.All(); len(got) != 1 || got[0].UseChar != 0 || got[0].LockFlag != 1 {
|
||||
t.Fatalf("cleared equipment=%+v", got)
|
||||
}
|
||||
if _, _, _, err := equipment.Handle("/EquipClear", request); err == nil {
|
||||
t.Fatal("already-cleared equipment accepted")
|
||||
}
|
||||
wrongCharacter := wire.AppendVarint(wire.AppendVarint(nil, 1, 10), 2, entry.InvenIndex)
|
||||
wrongCharacter = wire.AppendVarint(wrongCharacter, 3, 999)
|
||||
if _, _, _, err := equipment.Handle("/EquipClear", wrongCharacter); err == nil {
|
||||
t.Fatal("unknown character accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentChangeReplacesOnlyMatchingGameDataSlot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")),
|
||||
[]Character{{InvenIndex: 535607162, ID: 350, Level: 20}}, items, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "equipment.json")
|
||||
equipment, err := OpenEquipmentInventory(testStore(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := equipment.AttachCharacters(characters); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := equipment.AttachSlots(map[uint64]uint64{10010: 0, 943009: 0, 943619: 4}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, err := equipment.GrantOnce("old", 10010)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replacement, err := equipment.GrantOnce("replacement", 943009)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherSlot, err := equipment.GrantOnce("other-slot", 943619)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old.UseChar = 535607162
|
||||
otherSlot.UseChar = 535607162
|
||||
equipment.owned.Equipment[0] = old
|
||||
equipment.owned.Equipment[2] = otherSlot
|
||||
if err := equipment.commitLocked(cloneEquipmentSnapshot(equipment.owned), "test setup"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 7), 2, replacement.InvenIndex)
|
||||
request = wire.AppendVarint(request, 3, 535607162)
|
||||
code, response, handled, err := equipment.Handle("/EquipChange", request)
|
||||
if err != nil || !handled || code != 45 {
|
||||
t.Fatalf("change code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
character, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing response character: %v", err)
|
||||
}
|
||||
if index, _, _ := wire.Varint(character, 1); index != 535607162 {
|
||||
t.Fatalf("response character=%d", index)
|
||||
}
|
||||
restored, err := OpenEquipmentInventory(testStore(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := restored.All()
|
||||
if got[0].UseChar != 0 || got[1].UseChar != 535607162 || got[2].UseChar != 535607162 {
|
||||
t.Fatalf("changed equipment=%+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func applyStepUpProgress(next *collectionSnapshot, groupID, step uint64) error {
|
||||
if groupID == 0 && step == 0 {
|
||||
return nil
|
||||
}
|
||||
if groupID == 0 || step == 0 {
|
||||
return errors.New("player: incomplete step-up purchase")
|
||||
}
|
||||
key := strconv.FormatUint(groupID, 10)
|
||||
completed := next.StepUpProgress[key]
|
||||
if completed >= step {
|
||||
return nil
|
||||
}
|
||||
if completed+1 != step {
|
||||
return fmt.Errorf("player: step-up group %d expects step %d, got %d", groupID, completed+1, step)
|
||||
}
|
||||
next.StepUpProgress[key] = step
|
||||
return nil
|
||||
}
|
||||
|
||||
func gachaFixedKey(fixedID, fixedType uint64) string {
|
||||
return fmt.Sprintf("%d:%d", fixedID, fixedType)
|
||||
}
|
||||
|
||||
func dailyGachaPrefix(day string, groupID, buyType uint64) string {
|
||||
return fmt.Sprintf("daily-gacha:%s:%d:%d:", day, groupID, buyType)
|
||||
}
|
||||
|
||||
func applyGachaPurchase(next *collectionSnapshot, identity string, costumeIDs []uint64, purchase GachaPurchase, grant *CollectionGrant) error {
|
||||
if next.GachaApplied[identity] {
|
||||
return nil
|
||||
}
|
||||
groupKey := strconv.FormatUint(purchase.Group.ID, 10)
|
||||
user := next.GachaUsers[groupKey]
|
||||
user.GroupID = purchase.Group.ID
|
||||
if purchase.DailyLimit != 0 {
|
||||
if purchase.DailyKey == "" || (purchase.BuyType != 0 && purchase.BuyType != 2) {
|
||||
return errors.New("player: invalid daily gacha purchase")
|
||||
}
|
||||
prefix := dailyGachaPrefix(purchase.DailyKey, purchase.Group.ID, purchase.BuyType)
|
||||
var used uint64
|
||||
for key, applied := range next.GachaApplied {
|
||||
if applied && strings.HasPrefix(key, prefix) {
|
||||
used++
|
||||
}
|
||||
}
|
||||
if used >= purchase.DailyLimit {
|
||||
return fmt.Errorf("player: daily gacha group %d buy type %d exhausted", purchase.Group.ID, purchase.BuyType)
|
||||
}
|
||||
next.GachaApplied[prefix+identity] = true
|
||||
}
|
||||
point := purchase.Group.PointCount * uint64(len(costumeIDs))
|
||||
user.Point += point
|
||||
switch purchase.BuyType {
|
||||
case 1: // GB_NORMAL
|
||||
user.TotalBuyCount += uint64(len(costumeIDs))
|
||||
case 2: // GB_CASH outside the date-scoped daily discount
|
||||
if purchase.DailyLimit == 0 {
|
||||
if len(costumeIDs) == 1 {
|
||||
user.OneCashPickCount++
|
||||
} else {
|
||||
user.TenCashPickCount++
|
||||
}
|
||||
}
|
||||
case 3: // GB_CASH_CONTENT_TICKET
|
||||
user.TotalBuyCount += uint64(len(costumeIDs))
|
||||
}
|
||||
next.GachaUsers[groupKey] = user
|
||||
grant.GachaGroupID = purchase.Group.ID
|
||||
grant.GachaPoint = point
|
||||
grant.GachaFixed = append([]GachaFixedState(nil), purchase.Fixed...)
|
||||
grant.SelectionApplySortIDs = append([]uint64(nil), purchase.SelectionApplySortIDs...)
|
||||
for _, fixed := range purchase.Fixed {
|
||||
stored := fixed
|
||||
stored.ApplySort = -1
|
||||
next.GachaFixed[gachaFixedKey(fixed.FixedID, fixed.Type)] = stored
|
||||
}
|
||||
next.GachaApplied[identity] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CollectionStore) GachaUsers() []GachaUserState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
result := make([]GachaUserState, 0, len(s.data.GachaUsers))
|
||||
for _, value := range s.data.GachaUsers {
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].GroupID < result[j].GroupID })
|
||||
return result
|
||||
}
|
||||
|
||||
// GachaUsersForDay returns cumulative gacha state with daily free/paid counts
|
||||
// rebuilt from durable, date-scoped purchase markers. Historical counts never
|
||||
// leak into a new reset day and no storage migration is required.
|
||||
func (s *CollectionStore) GachaUsersForDay(day string) []GachaUserState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
byGroup := make(map[uint64]GachaUserState, len(s.data.GachaUsers))
|
||||
for _, value := range s.data.GachaUsers {
|
||||
value.OneFreePickCount = 0
|
||||
value.OneCashPickCount = 0
|
||||
value.TenFreePickCount = 0
|
||||
value.TenCashPickCount = 0
|
||||
byGroup[value.GroupID] = value
|
||||
}
|
||||
prefix := "daily-gacha:" + day + ":"
|
||||
for key, applied := range s.data.GachaApplied {
|
||||
if !applied || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimPrefix(key, prefix), ":", 3)
|
||||
if len(parts) != 3 {
|
||||
continue
|
||||
}
|
||||
groupID, groupErr := strconv.ParseUint(parts[0], 10, 64)
|
||||
buyType, typeErr := strconv.ParseUint(parts[1], 10, 64)
|
||||
if groupErr != nil || typeErr != nil || groupID == 0 {
|
||||
continue
|
||||
}
|
||||
user := byGroup[groupID]
|
||||
user.GroupID = groupID
|
||||
switch buyType {
|
||||
case 0:
|
||||
user.OneFreePickCount++
|
||||
case 2:
|
||||
user.OneCashPickCount++
|
||||
}
|
||||
byGroup[groupID] = user
|
||||
}
|
||||
result := make([]GachaUserState, 0, len(byGroup))
|
||||
for _, value := range byGroup {
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].GroupID < result[j].GroupID })
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *CollectionStore) GachaDailyCount(day string, groupID, buyType uint64) uint64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
prefix := dailyGachaPrefix(day, groupID, buyType)
|
||||
var count uint64
|
||||
for key, applied := range s.data.GachaApplied {
|
||||
if applied && strings.HasPrefix(key, prefix) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *CollectionStore) GachaUser(groupID uint64) GachaUserState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.GachaUsers[strconv.FormatUint(groupID, 10)]
|
||||
}
|
||||
|
||||
func (s *CollectionStore) GachaFixedStates() []GachaFixedState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
result := make([]GachaFixedState, 0, len(s.data.GachaFixed))
|
||||
for _, value := range s.data.GachaFixed {
|
||||
value.ApplySort = -1
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].FixedID != result[j].FixedID {
|
||||
return result[i].FixedID < result[j].FixedID
|
||||
}
|
||||
return result[i].Type < result[j].Type
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *CollectionStore) GachaFixedCount(fixedID, fixedType uint64) uint64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.GachaFixed[gachaFixedKey(fixedID, fixedType)].Count
|
||||
}
|
||||
|
||||
// ExchangeGachaPoint performs the authoritative point debit. The exchange is
|
||||
// saved before the wallet credit; a retry can therefore safely recover a
|
||||
// wallet write using the same identity without spending the points twice.
|
||||
func (s *CollectionStore) ExchangeGachaPoint(identity string, groupID, count uint64) (GachaPointExchange, error) {
|
||||
if identity == "" || groupID == 0 || count == 0 {
|
||||
return GachaPointExchange{}, errors.New("player: invalid gacha point exchange")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if exchange, ok := s.data.GachaPointExchange[identity]; ok {
|
||||
if exchange.GroupID != groupID || exchange.Count != count {
|
||||
return GachaPointExchange{}, errors.New("player: gacha point exchange identity conflict")
|
||||
}
|
||||
return exchange, nil
|
||||
}
|
||||
key := strconv.FormatUint(groupID, 10)
|
||||
user, ok := s.data.GachaUsers[key]
|
||||
if !ok || user.Point < count {
|
||||
return GachaPointExchange{}, errors.New("player: insufficient gacha point")
|
||||
}
|
||||
next := cloneCollection(s.data)
|
||||
user.Point -= count
|
||||
if math.MaxUint64-user.ExchangeMileageCount < count {
|
||||
return GachaPointExchange{}, errors.New("player: gacha point exchange count overflow")
|
||||
}
|
||||
user.ExchangeMileageCount += count
|
||||
next.GachaUsers[key] = user
|
||||
exchange := GachaPointExchange{GroupID: groupID, Count: count}
|
||||
next.GachaPointExchange[identity] = exchange
|
||||
if err := s.commit(next); err != nil {
|
||||
return GachaPointExchange{}, err
|
||||
}
|
||||
return exchange, nil
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type characterSnapshot struct {
|
||||
Version string `json:"version"`
|
||||
CharacterOrder []uint64 `json:"character_order"`
|
||||
}
|
||||
|
||||
// CharacterStore owns mutable character growth separately from immutable
|
||||
// character design data. The seed supplies the initially owned instances.
|
||||
type CharacterStore struct {
|
||||
mu sync.Mutex
|
||||
store stateio.AtomicEntryStore
|
||||
characters []Character
|
||||
persisted map[uint64]bool
|
||||
persistedOrder []uint64
|
||||
persistedCore bool
|
||||
inventory *Inventory
|
||||
gameDataRoot string
|
||||
gameDataVersion string
|
||||
grow func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error)
|
||||
collection *CollectionStore
|
||||
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 {
|
||||
if wallet == nil {
|
||||
return errors.New("player: nil growth wallet")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.wallet = wallet
|
||||
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 {
|
||||
if maxHealth == nil {
|
||||
return errors.New("player: nil maximum health calculator")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.maxHealth = maxHealth
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CharacterStore) AttachCollection(collection *CollectionStore) error {
|
||||
if collection == nil {
|
||||
return errors.New("player: nil collection store")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.collection = collection
|
||||
return nil
|
||||
}
|
||||
|
||||
func OpenCharacterStore(store stateio.Store, seed []Character, inventory *Inventory, gameDataRoot, gameDataVersion string) (*CharacterStore, error) {
|
||||
if store == nil || inventory == nil {
|
||||
return nil, errors.New("player: invalid character store configuration")
|
||||
}
|
||||
entries, ok := store.(stateio.AtomicEntryStore)
|
||||
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, 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)
|
||||
}
|
||||
s.promoteGrowth = func(character Character, submitted []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) {
|
||||
return gamedata.CharacterGrowthPromotions(s.gameDataRoot, s.gameDataVersion, int(character.ID), character.Level, character.Exp, submitted)
|
||||
}
|
||||
data, err := entries.Load("characters")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
orphaned, err := entries.ListEntries("characters", "characters")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("player: list character entries: %w", err)
|
||||
}
|
||||
if len(orphaned) != 0 {
|
||||
return nil, errors.New("player: character entries exist without core")
|
||||
}
|
||||
return s, validateCharacters(s.characters)
|
||||
}
|
||||
saved, loaded, err := loadCharacterEntries(entries, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.characters = loaded
|
||||
s.persistedOrder = append([]uint64{}, saved.CharacterOrder...)
|
||||
s.persistedCore = true
|
||||
seen := make(map[uint64]bool, len(s.characters))
|
||||
for _, character := range s.characters {
|
||||
seen[character.InvenIndex] = true
|
||||
s.persisted[character.InvenIndex] = true
|
||||
}
|
||||
for _, character := range seed {
|
||||
if !seen[character.InvenIndex] {
|
||||
return nil, fmt.Errorf("player: current character state omits seeded inventory index %d", character.InvenIndex)
|
||||
}
|
||||
}
|
||||
if err := validateCharacters(s.characters); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *CharacterStore) EnsurePersisted() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.persist(append([]Character(nil), s.characters...))
|
||||
}
|
||||
|
||||
func validateCharacters(characters []Character) error {
|
||||
seen := make(map[uint64]bool, len(characters))
|
||||
for _, character := range characters {
|
||||
if character.InvenIndex == 0 || character.ID == 0 || character.Level == 0 || seen[character.InvenIndex] {
|
||||
return errors.New("player: invalid saved character")
|
||||
}
|
||||
seen[character.InvenIndex] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CharacterStore) All() []Character {
|
||||
characters := s.RawAll()
|
||||
s.mu.Lock()
|
||||
maxHealth := s.maxHealth
|
||||
s.mu.Unlock()
|
||||
if maxHealth != nil {
|
||||
for i := range characters {
|
||||
if hp, err := maxHealth(characters[i]); err == nil {
|
||||
characters[i].HP = hp
|
||||
}
|
||||
}
|
||||
}
|
||||
return characters
|
||||
}
|
||||
|
||||
// RawAll is for account-ownership queries used by the stat calculator itself.
|
||||
// Calling All() from that calculator would recursively calculate its inputs.
|
||||
func (s *CharacterStore) RawAll() []Character {
|
||||
s.mu.Lock()
|
||||
characters := append([]Character(nil), s.characters...)
|
||||
collection := s.collection
|
||||
s.mu.Unlock()
|
||||
if collection != nil {
|
||||
characters = append(characters, collection.Characters()...)
|
||||
}
|
||||
return characters
|
||||
}
|
||||
|
||||
func (s *CharacterStore) Find(inventoryIndex uint64) (Character, bool) {
|
||||
s.mu.Lock()
|
||||
for _, character := range s.characters {
|
||||
if character.InvenIndex == inventoryIndex {
|
||||
maxHealth := s.maxHealth
|
||||
s.mu.Unlock()
|
||||
if maxHealth != nil {
|
||||
if hp, err := maxHealth(character); err == nil {
|
||||
character.HP = hp
|
||||
}
|
||||
}
|
||||
return character, true
|
||||
}
|
||||
}
|
||||
collection := s.collection
|
||||
s.mu.Unlock()
|
||||
if collection != nil {
|
||||
character, found := collection.FindCharacter(inventoryIndex)
|
||||
if found && s.maxHealth != nil {
|
||||
if hp, err := s.maxHealth(character); err == nil {
|
||||
character.HP = hp
|
||||
}
|
||||
}
|
||||
return character, found
|
||||
}
|
||||
return Character{}, false
|
||||
}
|
||||
|
||||
func (s *CharacterStore) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
if path == "/CharImmortal" {
|
||||
return s.charImmortal(request)
|
||||
}
|
||||
if path == "/TalentSkillUpgrade" {
|
||||
return s.talentSkillUpgrade(request)
|
||||
}
|
||||
if path != "/CharGrowth" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
index, found, err := wire.Varint(request, 2)
|
||||
if err != nil || !found || index == 0 {
|
||||
return 0, nil, true, errors.New("player: CharGrowth missing character")
|
||||
}
|
||||
var materials []Item
|
||||
if err := wire.Walk(request, func(field wire.Field) error {
|
||||
if field.Number != 3 {
|
||||
return nil
|
||||
}
|
||||
if field.Type != 2 {
|
||||
return errors.New("player: invalid growth material")
|
||||
}
|
||||
var item Item
|
||||
if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil {
|
||||
return err
|
||||
}
|
||||
if item.Type == 0 || item.Count == 0 || (item.Type != 4 && (item.InvenIndex == 0 || item.ID == 0)) {
|
||||
return errors.New("player: incomplete growth material")
|
||||
}
|
||||
materials = append(materials, item)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
if len(materials) == 0 {
|
||||
return 0, nil, true, errors.New("player: CharGrowth has no materials")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
position := -1
|
||||
for i, current := range s.characters {
|
||||
if current.InvenIndex == index {
|
||||
position = i
|
||||
break
|
||||
}
|
||||
}
|
||||
var current Character
|
||||
fromCollection := false
|
||||
if position >= 0 {
|
||||
current = s.characters[position]
|
||||
} else if s.collection != nil {
|
||||
current, fromCollection = s.collection.FindCharacter(index)
|
||||
}
|
||||
if position < 0 && !fromCollection {
|
||||
return 0, nil, true, fmt.Errorf("player: unknown character inventory index %d", index)
|
||||
}
|
||||
isPromotion := false
|
||||
for _, material := range materials {
|
||||
if material.Type == 4 {
|
||||
isPromotion = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isPromotion {
|
||||
return s.promoteCharacter(current, position, fromCollection, materials)
|
||||
}
|
||||
growthMaterials := make([]gamedata.GrowthMaterial, len(materials))
|
||||
for i, material := range materials {
|
||||
if material.Type != 8 {
|
||||
return 0, nil, true, fmt.Errorf("player: unsupported growth material type %d", material.Type)
|
||||
}
|
||||
growthMaterials[i] = gamedata.GrowthMaterial{ID: material.ID, Count: material.Count}
|
||||
}
|
||||
newLevel, newExp, refunds, err := s.grow(current, growthMaterials)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: calculate character growth: %w", err)
|
||||
}
|
||||
current.Level = newLevel
|
||||
current.Exp = newExp
|
||||
if s.maxHealth != nil {
|
||||
maxHealth := s.maxHealth
|
||||
s.mu.Unlock()
|
||||
hp, healthErr := maxHealth(current)
|
||||
s.mu.Lock()
|
||||
current.HP, err = hp, healthErr
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: calculate grown character maximum health: %w", err)
|
||||
}
|
||||
}
|
||||
returned, err := s.inventory.ConsumeAndRefund(materials, refunds)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: consume growth material: %w", err)
|
||||
}
|
||||
if fromCollection {
|
||||
if err := s.collection.UpdateCharacter(current.ID, current); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: persist collection character growth: %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 character growth: %w", err)
|
||||
}
|
||||
s.characters = next
|
||||
}
|
||||
response := wire.AppendBytes(nil, 1, CharacterWire(current))
|
||||
var bundle []byte
|
||||
for _, item := range returned {
|
||||
bundle = wire.AppendBytes(bundle, 1, ItemWire(item))
|
||||
}
|
||||
response = wire.AppendBytes(response, 2, bundle)
|
||||
return 433, response, true, nil
|
||||
}
|
||||
|
||||
func (s *CharacterStore) promoteCharacter(current Character, position int, fromCollection bool, materials []Item) (int, []byte, bool, error) {
|
||||
var items []Item
|
||||
requested := make(map[[2]uint64]uint64)
|
||||
var gold uint64
|
||||
for _, material := range materials {
|
||||
if material.Type == 4 {
|
||||
if material.InvenIndex != 0 || material.ID != 0 || gold != 0 {
|
||||
return 0, nil, true, errors.New("player: invalid promotion currency")
|
||||
}
|
||||
gold = material.Count
|
||||
} else if material.Type == 8 {
|
||||
items = append(items, material)
|
||||
key := [2]uint64{8, material.ID}
|
||||
if material.Count > ^uint64(0)-requested[key] {
|
||||
return 0, nil, true, errors.New("player: promotion material overflow")
|
||||
}
|
||||
requested[key] += material.Count
|
||||
} else {
|
||||
return 0, nil, true, fmt.Errorf("player: unsupported promotion item type %d", material.Type)
|
||||
}
|
||||
}
|
||||
if gold == 0 {
|
||||
return 0, nil, true, errors.New("player: promotion has no gold cost")
|
||||
}
|
||||
submitted := make([]gamedata.PromotionCost, 0, len(requested)+1)
|
||||
for key, count := range requested {
|
||||
submitted = append(submitted, gamedata.PromotionCost{Type: key[0], ID: key[1], Count: count})
|
||||
}
|
||||
submitted = append(submitted, gamedata.PromotionCost{Type: 4, Count: gold})
|
||||
result, err := s.promoteGrowth(current, submitted)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: calculate combined character promotion: %w; request=%+v", err, materials)
|
||||
}
|
||||
if gold != 0 && (s.wallet == nil || !s.wallet.CanSpendGold(gold)) {
|
||||
return 0, nil, true, errors.New("player: insufficient gold for promotion")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return 0, nil, true, errors.New("player: promotion has no item material")
|
||||
}
|
||||
previousID := current.ID
|
||||
current.ID = result.CharacterID
|
||||
current.Level = result.Level
|
||||
current.Exp = result.Exp
|
||||
if fromCollection {
|
||||
if err := s.collection.CanUpdateCharacter(previousID, current); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate promoted collection character: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.inventory.CanConsume(items); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate promotion items: %w", err)
|
||||
}
|
||||
if s.maxHealth != nil {
|
||||
maxHealth := s.maxHealth
|
||||
s.mu.Unlock()
|
||||
hp, healthErr := maxHealth(current)
|
||||
s.mu.Lock()
|
||||
if healthErr != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: calculate promoted character health: %w", healthErr)
|
||||
}
|
||||
current.HP = hp
|
||||
}
|
||||
if gold != 0 {
|
||||
identity := "char-promote:" + strconv.FormatUint(current.InvenIndex, 10) + ":" + strconv.FormatUint(current.ID, 10)
|
||||
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: consume promotion gold: %w", err)
|
||||
}
|
||||
}
|
||||
returned, err := s.inventory.ConsumeAndRefund(items, result.Refunds)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: consume promotion items: %w", err)
|
||||
}
|
||||
if fromCollection {
|
||||
if err := s.collection.UpdateCharacter(previousID, current); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: persist promoted collection character: %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 promoted character: %w", err)
|
||||
}
|
||||
s.characters = next
|
||||
}
|
||||
response := wire.AppendBytes(nil, 1, CharacterWire(current))
|
||||
var bundle []byte
|
||||
for _, item := range returned {
|
||||
bundle = wire.AppendBytes(bundle, 1, ItemWire(item))
|
||||
}
|
||||
if len(bundle) != 0 {
|
||||
response = wire.AppendBytes(response, 2, bundle)
|
||||
}
|
||||
return 433, response, true, nil
|
||||
}
|
||||
|
||||
// charImmortal completes the automatic post-battle revival for characters
|
||||
// whose TalentSkillTable.ClassType is 14. The story character 6010 has
|
||||
// ValueList[0]=10000 at every talent level (100%). The authoritative maximum
|
||||
// HP is recomputed by Find from the same calculator as character growth.
|
||||
func (s *CharacterStore) charImmortal(request []byte) (int, []byte, bool, error) {
|
||||
seq, present, err := wire.Varint(request, 1)
|
||||
if err != nil || !present || seq == 0 {
|
||||
return 0, nil, true, errors.New("player: CharImmortal missing sequence")
|
||||
}
|
||||
var indices []uint64
|
||||
err = wire.Walk(request, func(field wire.Field) error {
|
||||
if field.Number != 2 {
|
||||
return nil
|
||||
}
|
||||
if field.Type != 0 && field.Type != 2 {
|
||||
return errors.New("player: CharImmortal invalid inventory index field")
|
||||
}
|
||||
for data := field.Value; len(data) != 0; {
|
||||
index, count := binary.Uvarint(data)
|
||||
if count <= 0 || index == 0 {
|
||||
return errors.New("player: CharImmortal invalid inventory index")
|
||||
}
|
||||
indices = append(indices, index)
|
||||
data = data[count:]
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
if len(indices) == 0 || len(indices) > 5 {
|
||||
return 0, nil, true, fmt.Errorf("player: CharImmortal invalid character count %d", len(indices))
|
||||
}
|
||||
seen := make(map[uint64]bool, len(indices))
|
||||
var response []byte
|
||||
for _, index := range indices {
|
||||
if seen[index] {
|
||||
return 0, nil, true, fmt.Errorf("player: CharImmortal duplicate character %d", index)
|
||||
}
|
||||
seen[index] = true
|
||||
character, found := s.Find(index)
|
||||
if !found {
|
||||
return 0, nil, true, fmt.Errorf("player: CharImmortal unknown character %d", index)
|
||||
}
|
||||
response = wire.AppendBytes(response, 1, CharacterWire(character))
|
||||
}
|
||||
return 96, response, true, nil
|
||||
}
|
||||
|
||||
func CharacterWire(c Character) []byte {
|
||||
fields := []struct {
|
||||
n int
|
||||
v uint64
|
||||
}{{1, c.InvenIndex}, {2, c.ID}, {3, c.HP}, {4, c.Level}, {5, c.CostumeID}, {6, c.Exp}, {7, c.UseCostume}, {8, c.TalentLevel}, {9, c.TalentExp}, {10, c.SolidarityReward}, {11, c.ExpiryTime}, {13, c.ConnectPotentialCostume}}
|
||||
var out []byte
|
||||
for _, field := range fields {
|
||||
if field.v != 0 {
|
||||
out = wire.AppendVarint(out, field.n, field.v)
|
||||
}
|
||||
}
|
||||
for _, p := range c.Pictorialbook {
|
||||
book := wire.AppendVarint(wire.AppendVarint(nil, 1, p.ID), 2, p.GroupID)
|
||||
out = wire.AppendBytes(out, 12, book)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/versionconfig"
|
||||
)
|
||||
|
||||
func loadCharacterEntries(store stateio.AtomicEntryStore, core []byte) (characterSnapshot, []Character, error) {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(core, &fields); err != nil {
|
||||
return characterSnapshot{}, nil, fmt.Errorf("player: decode character core: %w", err)
|
||||
}
|
||||
if len(fields) != 2 || fields["version"] == nil || fields["character_order"] == nil {
|
||||
return characterSnapshot{}, nil, errors.New("player: invalid character core fields")
|
||||
}
|
||||
var saved characterSnapshot
|
||||
if err := json.Unmarshal(core, &saved); err != nil {
|
||||
return characterSnapshot{}, nil, fmt.Errorf("player: decode character core: %w", err)
|
||||
}
|
||||
if saved.Version != versionconfig.State() || saved.CharacterOrder == nil {
|
||||
return characterSnapshot{}, nil, errors.New("player: invalid character save version or order")
|
||||
}
|
||||
rows, err := store.ListEntries("characters", "characters")
|
||||
if err != nil {
|
||||
return characterSnapshot{}, nil, fmt.Errorf("player: list character entries: %w", err)
|
||||
}
|
||||
if len(rows) != len(saved.CharacterOrder) {
|
||||
return characterSnapshot{}, nil, errors.New("player: character order and entries differ")
|
||||
}
|
||||
owned := make(map[uint64]Character, len(rows))
|
||||
for key, payload := range rows {
|
||||
index, err := strconv.ParseUint(key, 10, 64)
|
||||
if err != nil || index == 0 || key != strconv.FormatUint(index, 10) {
|
||||
return characterSnapshot{}, nil, fmt.Errorf("player: invalid character entry key %q", key)
|
||||
}
|
||||
var character Character
|
||||
if err := json.Unmarshal(payload, &character); err != nil {
|
||||
return characterSnapshot{}, nil, fmt.Errorf("player: decode character entry %q: %w", key, err)
|
||||
}
|
||||
if character.InvenIndex != index {
|
||||
return characterSnapshot{}, nil, fmt.Errorf("player: character entry %q has mismatched index", key)
|
||||
}
|
||||
owned[index] = character
|
||||
}
|
||||
characters := make([]Character, 0, len(saved.CharacterOrder))
|
||||
seen := make(map[uint64]bool, len(saved.CharacterOrder))
|
||||
for _, index := range saved.CharacterOrder {
|
||||
character, found := owned[index]
|
||||
if index == 0 || seen[index] || !found {
|
||||
return characterSnapshot{}, nil, fmt.Errorf("player: invalid character order index %d", index)
|
||||
}
|
||||
characters = append(characters, character)
|
||||
seen[index] = true
|
||||
}
|
||||
if err := validateCharacters(characters); err != nil {
|
||||
return characterSnapshot{}, nil, err
|
||||
}
|
||||
return saved, characters, nil
|
||||
}
|
||||
|
||||
func (s *CharacterStore) persist(next []Character) error {
|
||||
if err := validateCharacters(next); err != nil {
|
||||
return err
|
||||
}
|
||||
previous := make(map[uint64]Character, len(s.characters))
|
||||
for _, character := range s.characters {
|
||||
previous[character.InvenIndex] = character
|
||||
}
|
||||
current := make(map[uint64]bool, len(next))
|
||||
order := make([]uint64, 0, len(next))
|
||||
changes := make([]stateio.EntryMutation, 0)
|
||||
for _, character := range next {
|
||||
index := character.InvenIndex
|
||||
current[index] = true
|
||||
order = append(order, index)
|
||||
if s.persisted[index] && reflect.DeepEqual(previous[index], character) {
|
||||
continue
|
||||
}
|
||||
payload, err := json.Marshal(character)
|
||||
if err != nil {
|
||||
return fmt.Errorf("player: encode character %d: %w", index, err)
|
||||
}
|
||||
changes = append(changes, stateio.EntryMutation{
|
||||
Bucket: "characters", Key: strconv.FormatUint(index, 10), Payload: payload,
|
||||
})
|
||||
}
|
||||
for index := range s.persisted {
|
||||
if !current[index] {
|
||||
changes = append(changes, stateio.EntryMutation{
|
||||
Bucket: "characters", Key: strconv.FormatUint(index, 10), Delete: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
var core []byte
|
||||
if !s.persistedCore || !reflect.DeepEqual(s.persistedOrder, order) {
|
||||
var err error
|
||||
core, err = json.Marshal(characterSnapshot{Version: versionconfig.State(), CharacterOrder: order})
|
||||
if err != nil {
|
||||
return fmt.Errorf("player: encode character core: %w", err)
|
||||
}
|
||||
}
|
||||
if core == nil && len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := s.store.SaveWithEntries("characters", core, changes); err != nil {
|
||||
return err
|
||||
}
|
||||
s.persisted = current
|
||||
s.persistedOrder = order
|
||||
s.persistedCore = true
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
)
|
||||
|
||||
func TestCharacterEntriesPreserveOrderAndUpdateOneEntity(t *testing.T) {
|
||||
storage := &collectionWriteSpy{Memory: stateio.NewMemory()}
|
||||
inventory, err := OpenInventory(storage, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed := []Character{
|
||||
{InvenIndex: 3, ID: 30, Level: 1},
|
||||
{InvenIndex: 1, ID: 10, Level: 1},
|
||||
{InvenIndex: 2, ID: 20, Level: 1},
|
||||
}
|
||||
characters, err := OpenCharacterStore(storage, seed, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := characters.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core, err := storage.Load("characters")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(core, []byte(`"characters"`)) || !bytes.Contains(core, []byte(`"character_order":[3,1,2]`)) {
|
||||
t.Fatalf("incorrect character core: %s", core)
|
||||
}
|
||||
if storage.coreWrites != 1 || len(storage.mutations) != 1 || len(storage.mutations[0]) != 3 {
|
||||
t.Fatalf("initial writes: cores=%d mutations=%+v", storage.coreWrites, storage.mutations)
|
||||
}
|
||||
next := append([]Character(nil), characters.characters...)
|
||||
next[1].Level = 20
|
||||
if err := characters.persist(next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters.characters = next
|
||||
if storage.coreWrites != 1 || len(storage.mutations) != 2 || len(storage.mutations[1]) != 1 || storage.mutations[1][0].Key != "1" {
|
||||
t.Fatalf("growth rewrote unrelated characters: cores=%d mutations=%+v", storage.coreWrites, storage.mutations)
|
||||
}
|
||||
reloaded, err := OpenCharacterStore(storage, nil, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := reloaded.RawAll(); !reflect.DeepEqual(got, next) {
|
||||
t.Fatalf("reloaded order or content differs: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharacterEntriesRejectIncompleteOrLegacyState(t *testing.T) {
|
||||
valid, err := json.Marshal(Character{InvenIndex: 1, ID: 10, Level: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrongIndex, err := json.Marshal(Character{InvenIndex: 2, ID: 10, Level: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
core string
|
||||
entries map[string][]byte
|
||||
}{
|
||||
{"legacy array", `{"version":"2.35.10","characters":[]}`, nil},
|
||||
{"duplicate order", `{"version":"2.35.10","character_order":[1,1]}`, map[string][]byte{"1": valid, "2": wrongIndex}},
|
||||
{"missing entry", `{"version":"2.35.10","character_order":[1]}`, nil},
|
||||
{"extra entry", `{"version":"2.35.10","character_order":[]}`, map[string][]byte{"1": valid}},
|
||||
{"mismatched index", `{"version":"2.35.10","character_order":[1]}`, map[string][]byte{"1": wrongIndex}},
|
||||
{"noncanonical key", `{"version":"2.35.10","character_order":[1]}`, map[string][]byte{"01": valid}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
storage := stateio.NewMemory()
|
||||
if err := storage.Save("characters", []byte(test.core)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, payload := range test.entries {
|
||||
if err := storage.PutEntry("characters", "characters", key, payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
inventory, err := OpenInventory(storage, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := OpenCharacterStore(storage, nil, inventory, "", ""); err == nil {
|
||||
t.Fatal("accepted invalid character state")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharacterEntriesRejectOrphansWithoutCore(t *testing.T) {
|
||||
storage := stateio.NewMemory()
|
||||
if err := storage.PutEntry("characters", "characters", "1", []byte(`{"inven_index":1,"id":10,"level":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inventory, err := OpenInventory(storage, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := OpenCharacterStore(storage, nil, inventory, "", ""); err == nil {
|
||||
t.Fatal("accepted orphaned character entry")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestCharImmortalReturnsFullOwnedSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
starter := &Starter{Version: "2.35.10"}
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned := []Character{
|
||||
{InvenIndex: 535604120, ID: 6010, HP: 7, Level: 1, TalentLevel: 1},
|
||||
{InvenIndex: 535607162, ID: 350, HP: 512, Level: 20, TalentLevel: 1},
|
||||
}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), owned, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packed := binary.AppendUvarint(nil, owned[0].InvenIndex)
|
||||
request := wire.AppendBytes(wire.AppendVarint(nil, 1, 23), 2, packed)
|
||||
code, response, ok, err := characters.Handle("/CharImmortal", request)
|
||||
if err != nil || !ok || code != 96 {
|
||||
t.Fatalf("immortal: code=%d handled=%v err=%v", code, ok, err)
|
||||
}
|
||||
encoded, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing revived character: %v", err)
|
||||
}
|
||||
index, _, _ := wire.Varint(encoded, 1)
|
||||
hp, _, _ := wire.Varint(encoded, 3)
|
||||
if index != owned[0].InvenIndex || hp != 7 {
|
||||
t.Fatalf("revived character index=%d hp=%d", index, hp)
|
||||
}
|
||||
for _, invalid := range [][]byte{
|
||||
wire.AppendVarint(wire.AppendVarint(nil, 1, 24), 2, 999),
|
||||
wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 25), 2, owned[0].InvenIndex), 2, owned[0].InvenIndex),
|
||||
wire.AppendVarint(nil, 1, 26),
|
||||
} {
|
||||
if _, _, handled, err := characters.Handle("/CharImmortal", invalid); err == nil || !handled {
|
||||
t.Fatalf("invalid immortal request accepted: handled=%v err=%v", handled, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharacterGrowthConsumesMaterialAndPersists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
starter := &Starter{Version: "2.35.10"}
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := inventory.GrantOnce("battle", []gamedata.BattleReward{{Type: 8, ID: 8, Count: 3}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: 350, Level: 1}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters.grow = func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) {
|
||||
return 20, 0, []gamedata.GrowthMaterial{{ID: 7, Count: 6}}, nil
|
||||
}
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
request = wire.AppendVarint(request, 2, 77)
|
||||
material := ItemWire(items[0])
|
||||
request = wire.AppendBytes(request, 3, material)
|
||||
code, response, ok, err := characters.Handle("/CharGrowth", request)
|
||||
if err != nil || !ok || code != 433 {
|
||||
t.Fatalf("growth: code=%d ok=%v err=%v", code, ok, err)
|
||||
}
|
||||
encoded, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("character response: %v", err)
|
||||
}
|
||||
level, found, err := wire.Varint(encoded, 4)
|
||||
if err != nil || !found || level != 20 {
|
||||
t.Fatalf("level=%d found=%v err=%v", level, found, err)
|
||||
}
|
||||
restored, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), nil, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := restored.All(); len(got) != 1 || got[0].Level != 20 {
|
||||
t.Fatalf("restored=%+v", got)
|
||||
}
|
||||
_, itemResponse, _, err := inventory.Handle("/ItemInfo", wire.AppendVarint(nil, 1, 2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned, found, err := wire.Bytes(itemResponse, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("refund absent: found=%v err=%v", found, err)
|
||||
}
|
||||
id, _, _ := wire.Varint(owned, 2)
|
||||
count, _, _ := wire.Varint(owned, 4)
|
||||
if id != 7 || count != 6 {
|
||||
t.Fatalf("wrong refund id=%d count=%d", id, count)
|
||||
}
|
||||
bundle, found, err := wire.Bytes(response, 2)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing growth reward bundle: %v", err)
|
||||
}
|
||||
reward, found, err := wire.Bytes(bundle, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing reward item: %v", err)
|
||||
}
|
||||
rewardID, _, _ := wire.Varint(reward, 2)
|
||||
if rewardID != 7 {
|
||||
t.Fatalf("reward id=%d", rewardID)
|
||||
}
|
||||
if err := inventory.Consume(items); err == nil {
|
||||
t.Fatal("already spent growth material accepted again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrowthAndImmortalShareDynamicMaximumHealth(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := inventory.GrantOnce("growth", []gamedata.BattleReward{{Type: 8, ID: 8, Count: 2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: 350, HP: 122, Level: 1}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters.grow = func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) {
|
||||
return 20, 0, nil, nil
|
||||
}
|
||||
if err := characters.AttachMaxHealth(func(character Character) (uint64, error) {
|
||||
// The callback must be safe to query account ownership while growth
|
||||
// changes the same CharacterStore.
|
||||
if len(characters.RawAll()) != 1 {
|
||||
t.Fatal("owned character disappeared during health calculation")
|
||||
}
|
||||
if character.Level == 20 {
|
||||
return 513, nil
|
||||
}
|
||||
return 122, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 11), 2, 77)
|
||||
request = wire.AppendBytes(request, 3, ItemWire(items[0]))
|
||||
_, response, _, err := characters.Handle("/CharGrowth", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grown, _, err := wire.Bytes(response, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hp, _, _ := wire.Varint(grown, 3); hp != 513 {
|
||||
t.Fatalf("grown HP=%d want 513", hp)
|
||||
}
|
||||
immortal := wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 77)
|
||||
_, response, _, err = characters.Handle("/CharImmortal", immortal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revived, _, err := wire.Bytes(response, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hp, _, _ := wire.Varint(revived, 3); hp != 513 {
|
||||
t.Fatalf("revived HP=%d want 513", hp)
|
||||
}
|
||||
data, found, err := testStore(filepath.Join(dir, "characters.json")).(stateio.EntryStore).LoadEntry("characters", "characters", "77")
|
||||
if err != nil || !found || !bytes.Contains(data, []byte(`"hp":513`)) {
|
||||
t.Fatalf("growth HP was not persisted: %s err=%v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharacterPromotionUsesExactGameDataCosts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := inventory.GrantOnce("promotion-material", []gamedata.BattleReward{{Type: 8, ID: 11, Count: 2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1500})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: 350, Level: 20, HP: 513}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := characters.AttachWallet(wallet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters.promoteGrowth = func(character Character, submitted []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) {
|
||||
if character.ID != 350 || character.Level != 20 {
|
||||
return gamedata.PromotionGrowthResult{}, fmt.Errorf("not promotable: %+v", character)
|
||||
}
|
||||
if !promotionCostsEqual(submitted, []gamedata.PromotionCost{{Type: 8, ID: 11, Count: 1}, {Type: 4, Count: 1000}}) {
|
||||
return gamedata.PromotionGrowthResult{}, fmt.Errorf("unexpected submitted costs: %+v", submitted)
|
||||
}
|
||||
return gamedata.PromotionGrowthResult{CharacterID: 351, Level: 20, Costs: submitted}, nil
|
||||
}
|
||||
item := items[0]
|
||||
item.Count = 1
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77)
|
||||
request = wire.AppendBytes(request, 3, ItemWire(item))
|
||||
request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 1000}))
|
||||
code, response, handled, err := characters.Handle("/CharGrowth", request)
|
||||
if err != nil || !handled || code != 433 {
|
||||
t.Fatalf("promote code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
encoded, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing promoted character %v", err)
|
||||
}
|
||||
if id, _, _ := wire.Varint(encoded, 2); id != 351 {
|
||||
t.Fatalf("promoted id=%d", id)
|
||||
}
|
||||
if snapshot := wallet.Snapshot(); snapshot.Gold != 500 {
|
||||
t.Fatalf("gold after promotion=%d", snapshot.Gold)
|
||||
}
|
||||
loaded, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), nil, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loaded.All(); len(got) != 1 || got[0].ID != 351 {
|
||||
t.Fatalf("persisted promotion=%+v", got)
|
||||
}
|
||||
if err := inventory.Consume([]Item{item}); err != nil {
|
||||
t.Fatalf("remaining material x1 should exist: %v", err)
|
||||
}
|
||||
if _, _, _, err := characters.Handle("/CharGrowth", request); err == nil {
|
||||
t.Fatal("duplicate class-up accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharacterGrowthPromotesAndLevelsInOneRequestAcrossStacks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := inventory.GrantOnce("slime-a", []gamedata.BattleReward{{Type: 8, ID: 9, Count: 7}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := inventory.GrantOnce("slime-b", []gamedata.BattleReward{{Type: 8, ID: 9, Count: 103}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
classUp, err := inventory.GrantOnce("class-up", []gamedata.BattleReward{{Type: 8, ID: 12, Count: 3}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 3000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: 351, Level: 40}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := characters.AttachWallet(wallet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters.promoteGrowth = func(character Character, submitted []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) {
|
||||
if character.ID != 351 || character.Level != 40 {
|
||||
return gamedata.PromotionGrowthResult{}, fmt.Errorf("unexpected stage: %+v", character)
|
||||
}
|
||||
if !promotionCostsEqual(submitted, []gamedata.PromotionCost{{Type: 8, ID: 9, Count: 110}, {Type: 8, ID: 12, Count: 2}, {Type: 4, Count: 2000}}) {
|
||||
return gamedata.PromotionGrowthResult{}, fmt.Errorf("unexpected submitted costs: %+v", submitted)
|
||||
}
|
||||
return gamedata.PromotionGrowthResult{CharacterID: 352, Level: 60, Costs: submitted, Refunds: []gamedata.GrowthMaterial{{ID: 7, Count: 1}}}, nil
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77)
|
||||
for _, material := range []Item{first[0], second[0], {InvenIndex: classUp[0].InvenIndex, ID: 12, Type: 8, Count: 2}, {Type: 4, Count: 2000}} {
|
||||
request = wire.AppendBytes(request, 3, ItemWire(material))
|
||||
}
|
||||
code, response, handled, err := characters.Handle("/CharGrowth", request)
|
||||
if err != nil || !handled || code != 433 {
|
||||
t.Fatalf("combined growth code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
encoded, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing character: %v", err)
|
||||
}
|
||||
if id, _, _ := wire.Varint(encoded, 2); id != 352 {
|
||||
t.Fatalf("promoted character id=%d", id)
|
||||
}
|
||||
if level, _, _ := wire.Varint(encoded, 4); level != 60 {
|
||||
t.Fatalf("grown level=%d", level)
|
||||
}
|
||||
if wallet.Snapshot().Gold != 1000 {
|
||||
t.Fatalf("gold=%d", wallet.Snapshot().Gold)
|
||||
}
|
||||
if err := inventory.Consume([]Item{first[0]}); err == nil {
|
||||
t.Fatal("first experience stack was not consumed")
|
||||
}
|
||||
if err := inventory.Consume([]Item{second[0]}); err == nil {
|
||||
t.Fatal("second experience stack was not consumed")
|
||||
}
|
||||
if err := inventory.Consume([]Item{{InvenIndex: classUp[0].InvenIndex, ID: 12, Type: 8, Count: 1}}); err != nil {
|
||||
t.Fatalf("one unspent class-up material must remain: %v", err)
|
||||
}
|
||||
if bundle, found, err := wire.Bytes(response, 2); err != nil || !found || len(bundle) == 0 {
|
||||
t.Fatalf("missing refunded slime bundle: found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func promotionCostsEqual(got, want []gamedata.PromotionCost) bool {
|
||||
counts := make(map[[2]uint64]uint64, len(got))
|
||||
for _, cost := range got {
|
||||
counts[[2]uint64{cost.Type, cost.ID}] += cost.Count
|
||||
}
|
||||
if len(counts) != len(want) {
|
||||
return false
|
||||
}
|
||||
for _, cost := range want {
|
||||
if counts[[2]uint64{cost.Type, cost.ID}] != cost.Count {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestCollectionCharacterCombinedGrowthChangesIDWithoutChargingTwice(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := inventory.GrantOnce("growth-materials", []gamedata.BattleReward{{Type: 8, ID: 9, Count: 800}, {Type: 8, ID: 11, Count: 1}, {Type: 8, ID: 12, Count: 2}, {Type: 8, ID: 13, Count: 3}, {Type: 8, ID: 14, Count: 4}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 12000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collectionPath := filepath.Join(dir, "collection.json")
|
||||
collection, err := OpenCollectionStore(testStore(collectionPath), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nextCollection := cloneCollection(collection.data)
|
||||
nextCollection.Characters = []Character{{InvenIndex: 920000054, ID: 6510, Level: 1}}
|
||||
if err := collection.commit(nextCollection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: 350, Level: 1}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := characters.AttachWallet(wallet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := characters.AttachCollection(collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters.promoteGrowth = func(character Character, submitted []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) {
|
||||
if character.ID != 6510 || character.Level != 1 || !promotionCostsEqual(submitted, []gamedata.PromotionCost{{Type: 8, ID: 9, Count: 753}, {Type: 8, ID: 11, Count: 1}, {Type: 8, ID: 12, Count: 2}, {Type: 8, ID: 13, Count: 3}, {Type: 8, ID: 14, Count: 4}, {Type: 4, Count: 10000}}) {
|
||||
return gamedata.PromotionGrowthResult{}, fmt.Errorf("unexpected combined growth: %+v %+v", character, submitted)
|
||||
}
|
||||
return gamedata.PromotionGrowthResult{CharacterID: 6514, Level: 100, Refunds: []gamedata.GrowthMaterial{{ID: 7, Count: 3}}}, nil
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 920000054)
|
||||
for i, material := range items {
|
||||
if i == 0 {
|
||||
material.Count = 753
|
||||
}
|
||||
request = wire.AppendBytes(request, 3, ItemWire(material))
|
||||
}
|
||||
request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 10000}))
|
||||
code, _, handled, err := characters.Handle("/CharGrowth", request)
|
||||
if err != nil || !handled || code != 433 {
|
||||
t.Fatalf("collection promotion code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
if got, found := collection.FindCharacter(920000054); !found || got.ID != 6514 || got.Level != 100 {
|
||||
t.Fatalf("promoted collection character=%+v found=%v", got, found)
|
||||
}
|
||||
if wallet.Snapshot().Gold != 2000 {
|
||||
t.Fatalf("gold=%d", wallet.Snapshot().Gold)
|
||||
}
|
||||
if _, _, _, err := characters.Handle("/CharGrowth", request); err == nil {
|
||||
t.Fatal("replay of the old promotion was accepted")
|
||||
}
|
||||
if wallet.Snapshot().Gold != 2000 {
|
||||
t.Fatal("replay charged gold again")
|
||||
}
|
||||
reloaded, err := OpenCollectionStore(testStore(collectionPath), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, found := reloaded.FindCharacter(920000054); !found || got.ID != 6514 || got.Level != 100 {
|
||||
t.Fatalf("reloaded promoted collection character=%+v found=%v", got, found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharacterStoreRejectsSeedDrift(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
starter := &Starter{Version: "2.35.10"}
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 1, ID: 10, Level: 1}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := first.persist(first.All()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 1, ID: 10, Level: 1}, {InvenIndex: 2, ID: 20, Level: 15}}, inventory, "", ""); err == nil {
|
||||
t.Fatal("accepted seed characters absent from current persisted state")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/versionconfig"
|
||||
"bd2server/internal/server/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.State(), 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.State() ||
|
||||
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/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/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,146 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
)
|
||||
|
||||
func TestWalletLedgerUsesEntries(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
wallet, err := OpenWallet(store, Currency{Gold: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := wallet.SpendGoldOnce("upgrade:1", 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := wallet.GrantQuestOnce("quest:1", []gamedata.Reward{{Type: 4, Count: 20}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core, err := store.Load("wallet")
|
||||
if err != nil || bytes.Contains(core, []byte(`"granted"`)) || bytes.Contains(core, []byte(`"spent"`)) {
|
||||
t.Fatalf("wallet core contains ledger: %s, %v", core, err)
|
||||
}
|
||||
for _, bucket := range []string{"granted", "spent"} {
|
||||
entries, err := store.ListEntries("wallet", bucket)
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("%s entries=%v: %v", bucket, entries, err)
|
||||
}
|
||||
}
|
||||
reopened, err := OpenWallet(store, Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reopened.WasSpent("upgrade:1") || !reopened.WasGranted("quest:1") || reopened.Snapshot().Gold != 110 {
|
||||
t.Fatalf("reopened wallet=%+v", reopened.Snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryEntitiesAndGrantLedgerUseEntries(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
starter := &Starter{Version: "2.35.10"}
|
||||
inventory, err := OpenInventory(store, starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := inventory.GrantOnce("battle:1", []gamedata.BattleReward{{Type: 8, ID: 21, Count: 3}})
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("grant=%v: %v", items, err)
|
||||
}
|
||||
core, err := store.Load("items")
|
||||
if err != nil || bytes.Contains(core, []byte(`"items"`)) || bytes.Contains(core, []byte(`"granted"`)) || bytes.Contains(core, []byte(`"grant_items"`)) {
|
||||
t.Fatalf("items core contains entries: %s, %v", core, err)
|
||||
}
|
||||
for _, bucket := range []string{"items", "granted", "grant_items"} {
|
||||
entries, err := store.ListEntries("items", bucket)
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("%s entries=%v: %v", bucket, entries, err)
|
||||
}
|
||||
}
|
||||
reopened, err := OpenInventory(store, starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := reopened.GrantedItems("battle:1"); len(got) != 1 || got[0].InvenIndex != items[0].InvenIndex {
|
||||
t.Fatalf("reopened granted items=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentEntitiesAndGrantLedgerUseEntries(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
equipment, err := OpenEquipmentInventory(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := equipment.GrantOnce("quest:1", 943035)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core, err := store.Load("equipment")
|
||||
if err != nil || bytes.Contains(core, []byte(`"equipment"`)) || bytes.Contains(core, []byte(`"granted"`)) {
|
||||
t.Fatalf("equipment core contains entries: %s, %v", core, err)
|
||||
}
|
||||
for _, bucket := range []string{"equipment", "granted"} {
|
||||
entries, err := store.ListEntries("equipment", bucket)
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("%s entries=%v: %v", bucket, entries, err)
|
||||
}
|
||||
}
|
||||
reopened, err := OpenEquipmentInventory(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, ok := reopened.Granted("quest:1"); !ok || got.InvenIndex != first.InvenIndex {
|
||||
t.Fatalf("reopened grant=%+v, %t", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyInventoryAndEquipmentEnsureCore(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inventory.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if core, err := store.Load("items"); err != nil || core == nil {
|
||||
t.Fatalf("items core=%q: %v", core, err)
|
||||
}
|
||||
equipment, err := OpenEquipmentInventory(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := equipment.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if core, err := store.Load("equipment"); err != nil || core == nil {
|
||||
t.Fatalf("equipment core=%q: %v", core, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineLedgersAndEntitiesRejected(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
core []byte
|
||||
open func(*stateio.Memory) error
|
||||
}{
|
||||
{"wallet", []byte(`{"version":"2.35.10","equip_mileage":0,"equip_mileage_exchange_gage":0,"granted":{}}`), func(s *stateio.Memory) error { _, err := OpenWallet(s, Currency{}); return err }},
|
||||
{"items", []byte(`{"version":"2.35.10","next_index":900000001,"items":[]}`), func(s *stateio.Memory) error { _, err := OpenInventory(s, &Starter{Version: "2.35.10"}); return err }},
|
||||
{"equipment", []byte(`{"version":"2.35.10","next_index":910000001,"equipment":[]}`), func(s *stateio.Memory) error { _, err := OpenEquipmentInventory(s); return err }},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
if err := store.Save(tt.name, tt.core); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tt.open(store); err == nil {
|
||||
t.Fatal("accepted inline old-format state")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/versionconfig"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
// Inventory holds player-owned rewards separately from the immutable starter
|
||||
// seed. GrantOnce uses a battle identity to prevent double-credit on retries.
|
||||
type Inventory struct {
|
||||
mu sync.Mutex
|
||||
store stateio.AtomicEntryStore
|
||||
starter *Starter
|
||||
randomBoxes *gamedata.RandomBoxDesign
|
||||
owned ownedSnapshot
|
||||
persisted ownedSnapshot
|
||||
corePresent bool
|
||||
}
|
||||
|
||||
type ownedSnapshot struct {
|
||||
Version string `json:"version"`
|
||||
NextIndex uint64 `json:"next_index"`
|
||||
Items []Item `json:"-"`
|
||||
Granted map[string]bool `json:"-"`
|
||||
GrantItems map[string][]uint64 `json:"-"`
|
||||
}
|
||||
|
||||
func OpenInventory(store stateio.Store, starter *Starter) (*Inventory, error) {
|
||||
entries, ok := store.(stateio.AtomicEntryStore)
|
||||
if starter == nil || starter.Validate() != nil || !ok {
|
||||
return nil, errors.New("player: invalid inventory configuration")
|
||||
}
|
||||
s := &Inventory{store: entries, starter: starter, owned: ownedSnapshot{Version: versionconfig.State(), NextIndex: 900000001, Granted: map[string]bool{}, GrantItems: map[string][]uint64{}}}
|
||||
data, err := store.Load("items")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data != nil {
|
||||
s.corePresent = true
|
||||
if err := stateio.RequireExactJSONObject(data, "version", "next_index"); err != nil {
|
||||
return nil, fmt.Errorf("player: incompatible inventory layout: %w", err)
|
||||
}
|
||||
var shape map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &shape); err != nil {
|
||||
return nil, fmt.Errorf("player: decode inventory shape: %w", err)
|
||||
}
|
||||
for _, name := range []string{"items", "granted", "grant_items"} {
|
||||
if _, exists := shape[name]; exists {
|
||||
return nil, fmt.Errorf("player: inventory %s must use entries", name)
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(data, &s.owned); err != nil {
|
||||
return nil, fmt.Errorf("player: decode inventory: %w", err)
|
||||
}
|
||||
} else if err := stateio.RequireNoEntries(entries, "items", "items", "granted", "grant_items"); err != nil {
|
||||
return nil, fmt.Errorf("player: invalid inventory storage: %w", err)
|
||||
}
|
||||
if s.owned.Version != versionconfig.State() || s.owned.NextIndex < 900000001 {
|
||||
return nil, errors.New("player: invalid saved inventory")
|
||||
}
|
||||
s.owned.Granted, err = loadBoolEntries(entries, "items", "granted")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawGrantItems, err := entries.ListEntries("items", "grant_items")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.owned.GrantItems = make(map[string][]uint64, len(rawGrantItems))
|
||||
for key, value := range rawGrantItems {
|
||||
var indices []uint64
|
||||
if key == "" || json.Unmarshal(value, &indices) != nil {
|
||||
return nil, fmt.Errorf("player: invalid items grant_items entry %q", key)
|
||||
}
|
||||
s.owned.GrantItems[key] = indices
|
||||
}
|
||||
rawItems, err := entries.ListEntries("items", "items")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range rawItems {
|
||||
var item Item
|
||||
index, parseErr := strconv.ParseUint(key, 10, 64)
|
||||
if parseErr != nil || json.Unmarshal(value, &item) != nil || item.InvenIndex != index {
|
||||
return nil, fmt.Errorf("player: invalid items entry %q", key)
|
||||
}
|
||||
s.owned.Items = append(s.owned.Items, item)
|
||||
}
|
||||
sort.Slice(s.owned.Items, func(i, j int) bool { return s.owned.Items[i].InvenIndex < s.owned.Items[j].InvenIndex })
|
||||
s.persisted = cloneOwnedSnapshot(s.owned)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Inventory) EnsurePersisted() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
data, err := s.store.Load("items")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data != nil {
|
||||
return nil
|
||||
}
|
||||
return s.commitOwned(cloneOwnedSnapshot(s.owned))
|
||||
}
|
||||
|
||||
func (s *Inventory) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
if path != "/ItemInfo" && path != "/UseRandomBox" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
if path == "/UseRandomBox" {
|
||||
return s.useRandomBox(request)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
items := make([]Item, 0, len(s.starter.Items)+len(s.owned.Items))
|
||||
items = append(items, s.starter.Items...)
|
||||
items = append(items, s.owned.Items...)
|
||||
return (&Starter{Version: versionconfig.State(), Items: items}).Handle(path, request)
|
||||
}
|
||||
|
||||
// AttachRandomBoxes installs the version-validated deterministic RandomBox
|
||||
// definitions. It is supplied at process startup from real GameData rather
|
||||
// than accepting a client-supplied reward.
|
||||
func (s *Inventory) AttachRandomBoxes(design *gamedata.RandomBoxDesign) error {
|
||||
if s == nil || design == nil {
|
||||
return errors.New("player: nil random box design")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.randomBoxes = design
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Inventory) useRandomBox(request []byte) (int, []byte, bool, error) {
|
||||
seq, present, err := wire.Varint(request, 1)
|
||||
if err != nil || !present || seq == 0 {
|
||||
return 0, nil, true, errors.New("player: UseRandomBox invalid sequence")
|
||||
}
|
||||
index, present, err := wire.Varint(request, 2)
|
||||
if err != nil || !present || index == 0 {
|
||||
return 0, nil, true, errors.New("player: UseRandomBox invalid inventory index")
|
||||
}
|
||||
count, present, err := wire.Varint(request, 3)
|
||||
if err != nil || !present || count == 0 || count > uint64(^uint32(0)>>1) {
|
||||
return 0, nil, true, errors.New("player: UseRandomBox invalid use count")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.randomBoxes == nil {
|
||||
return 0, nil, true, errors.New("player: UseRandomBox design unavailable")
|
||||
}
|
||||
boxAt := -1
|
||||
for i, item := range s.owned.Items {
|
||||
if item.InvenIndex == index {
|
||||
boxAt = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if boxAt < 0 {
|
||||
return 0, nil, true, fmt.Errorf("player: UseRandomBox unknown inventory index %d", index)
|
||||
}
|
||||
box := s.owned.Items[boxAt]
|
||||
if box.Type != 9 || box.Count < count {
|
||||
return 0, nil, true, errors.New("player: UseRandomBox item or count mismatch")
|
||||
}
|
||||
rewards, err := s.randomBoxes.Open(box.ID, count)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
next := cloneOwnedSnapshot(s.owned)
|
||||
if box.Count == count {
|
||||
next.Items = append(next.Items[:boxAt], next.Items[boxAt+1:]...)
|
||||
} else {
|
||||
next.Items[boxAt].Count -= count
|
||||
}
|
||||
granted := make([]Item, 0, len(rewards))
|
||||
for _, reward := range rewards {
|
||||
if reward.Type == 0 || reward.ID == 0 || reward.Count == 0 {
|
||||
return 0, nil, true, errors.New("player: UseRandomBox invalid GameData reward")
|
||||
}
|
||||
item := Item{InvenIndex: next.NextIndex, ID: reward.ID, Type: reward.Type, Count: reward.Count, TimeValue: uint64(time.Now().UnixMilli())}
|
||||
next.NextIndex++
|
||||
next.Items = append(next.Items, item)
|
||||
granted = append(granted, item)
|
||||
}
|
||||
if err := s.commitOwned(next); err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
s.owned = next
|
||||
var bundle []byte
|
||||
for _, item := range granted {
|
||||
bundle = wire.AppendBytes(bundle, 1, ItemWire(item))
|
||||
}
|
||||
return 143, wire.AppendBytes(nil, 1, bundle), true, nil
|
||||
}
|
||||
|
||||
func (s *Inventory) All() []Item {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
items := make([]Item, 0, len(s.starter.Items)+len(s.owned.Items))
|
||||
items = append(items, s.starter.Items...)
|
||||
items = append(items, s.owned.Items...)
|
||||
return items
|
||||
}
|
||||
|
||||
// SelectMutable returns concrete owned stacks for a server-calculated cost.
|
||||
// Starter seed items are immutable and deliberately excluded, matching
|
||||
// Consume. Results preserve inventory order and split the final stack exactly.
|
||||
func (s *Inventory) SelectMutable(itemType, id, count uint64) ([]Item, error) {
|
||||
if itemType == 0 || id == 0 || count == 0 {
|
||||
return nil, errors.New("player: invalid mutable item selection")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
remaining := count
|
||||
var selected []Item
|
||||
for _, item := range s.owned.Items {
|
||||
if item.Type != itemType || item.ID != id || item.Count == 0 {
|
||||
continue
|
||||
}
|
||||
use := min(item.Count, remaining)
|
||||
copy := item
|
||||
copy.Count = use
|
||||
selected = append(selected, copy)
|
||||
remaining -= use
|
||||
if remaining == 0 {
|
||||
return selected, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("player: insufficient mutable item %d/%d: have %d want %d", itemType, id, count-remaining, count)
|
||||
}
|
||||
|
||||
func (s *Inventory) GrantOnce(identity string, rewards []gamedata.BattleReward) ([]Item, error) {
|
||||
if identity == "" {
|
||||
return nil, errors.New("player: missing reward identity")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.owned.Granted[identity] {
|
||||
return nil, nil
|
||||
}
|
||||
next := cloneOwnedSnapshot(s.owned)
|
||||
newItems := make([]Item, 0, len(rewards))
|
||||
for _, r := range rewards {
|
||||
if r.ID == 0 || r.Type == 0 || r.Count == 0 {
|
||||
return nil, errors.New("player: invalid battle reward")
|
||||
}
|
||||
item := Item{InvenIndex: next.NextIndex, ID: r.ID, Type: r.Type, Count: r.Count, TimeValue: uint64(time.Now().UnixMilli())}
|
||||
next.NextIndex++
|
||||
next.Items = append(next.Items, item)
|
||||
newItems = append(newItems, item)
|
||||
next.GrantItems[identity] = append(next.GrantItems[identity], item.InvenIndex)
|
||||
}
|
||||
next.Granted[identity] = true
|
||||
if err := s.commitOwned(next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.owned = next
|
||||
return newItems, nil
|
||||
}
|
||||
|
||||
func cloneOwnedSnapshot(current ownedSnapshot) ownedSnapshot {
|
||||
next := ownedSnapshot{Version: current.Version, NextIndex: current.NextIndex,
|
||||
Items: append([]Item(nil), current.Items...), Granted: make(map[string]bool, len(current.Granted)+1), GrantItems: make(map[string][]uint64, len(current.GrantItems)+1)}
|
||||
for k, v := range current.Granted {
|
||||
next.Granted[k] = v
|
||||
}
|
||||
for k, v := range current.GrantItems {
|
||||
next.GrantItems[k] = append([]uint64(nil), v...)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// commitOwned persists a fully validated next snapshot. Caller holds s.mu.
|
||||
func (s *Inventory) commitOwned(next ownedSnapshot) error {
|
||||
data, err := json.Marshal(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changes := make([]stateio.EntryMutation, 0)
|
||||
for key, value := range next.Granted {
|
||||
if value && !s.persisted.Granted[key] {
|
||||
changes = append(changes, stateio.EntryMutation{Bucket: "granted", Key: key, Payload: []byte("true")})
|
||||
}
|
||||
}
|
||||
for key, value := range next.GrantItems {
|
||||
if !equalIndices(value, s.persisted.GrantItems[key]) {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changes = append(changes, stateio.EntryMutation{Bucket: "grant_items", Key: key, Payload: payload})
|
||||
}
|
||||
}
|
||||
before := make(map[uint64]Item, len(s.persisted.Items))
|
||||
for _, item := range s.persisted.Items {
|
||||
before[item.InvenIndex] = item
|
||||
}
|
||||
for _, item := range next.Items {
|
||||
old, exists := before[item.InvenIndex]
|
||||
if !exists || !equalItem(old, item) {
|
||||
payload, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changes = append(changes, stateio.EntryMutation{Bucket: "items", Key: strconv.FormatUint(item.InvenIndex, 10), Payload: payload})
|
||||
}
|
||||
delete(before, item.InvenIndex)
|
||||
}
|
||||
for index := range before {
|
||||
changes = append(changes, stateio.EntryMutation{Bucket: "items", Key: strconv.FormatUint(index, 10), Delete: true})
|
||||
}
|
||||
if s.corePresent && next.Version == s.persisted.Version && next.NextIndex == s.persisted.NextIndex {
|
||||
data = nil
|
||||
}
|
||||
if err := s.store.SaveWithEntries("items", data, changes); err != nil {
|
||||
return err
|
||||
}
|
||||
s.corePresent = true
|
||||
s.persisted = cloneOwnedSnapshot(next)
|
||||
return nil
|
||||
}
|
||||
|
||||
func equalIndices(a, b []uint64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalItem(a, b Item) bool { return reflect.DeepEqual(a, b) }
|
||||
|
||||
// GrantedItems returns the stable instances created by a previous GrantOnce.
|
||||
// Legacy grants made before instance tracking return an empty slice.
|
||||
func (s *Inventory) GrantedItems(identity string) []Item {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
indexes := s.owned.GrantItems[identity]
|
||||
result := make([]Item, 0, len(indexes))
|
||||
for _, index := range indexes {
|
||||
for _, item := range s.owned.Items {
|
||||
if item.InvenIndex == index {
|
||||
result = append(result, item)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// WasGranted reports whether GrantOnce committed the identity even when all
|
||||
// granted item stacks were later consumed. Cash-product purchase counts use
|
||||
// this durable marker rather than the current inventory contents.
|
||||
func (s *Inventory) WasGranted(identity string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.owned.Granted[identity]
|
||||
}
|
||||
|
||||
// Consume atomically removes the requested counts from mutable owned items.
|
||||
// Starter seed entries are immutable and are deliberately not accepted here.
|
||||
func (s *Inventory) CanConsume(requested []Item) error {
|
||||
if len(requested) == 0 {
|
||||
return errors.New("player: no items to consume")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
remaining := make(map[uint64]Item, len(s.owned.Items))
|
||||
for _, item := range s.owned.Items {
|
||||
remaining[item.InvenIndex] = item
|
||||
}
|
||||
for _, want := range requested {
|
||||
if want.InvenIndex == 0 || want.ID == 0 || want.Type == 0 || want.Count == 0 {
|
||||
return errors.New("player: invalid item consumption")
|
||||
}
|
||||
item, ok := remaining[want.InvenIndex]
|
||||
if !ok || item.ID != want.ID || item.Type != want.Type || item.Count < want.Count {
|
||||
return fmt.Errorf("player: item %d consumption mismatch", want.InvenIndex)
|
||||
}
|
||||
item.Count -= want.Count
|
||||
remaining[want.InvenIndex] = item
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Inventory) Consume(requested []Item) error {
|
||||
_, err := s.ConsumeAndRefund(requested, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// ConsumeAndRefund changes the spent stack and returned growth resources in a
|
||||
// single inventory save, keeping ItemInfo and RewardInfoBundle consistent.
|
||||
func (s *Inventory) ConsumeAndRefund(requested []Item, refunds []gamedata.GrowthMaterial) ([]Item, error) {
|
||||
if len(requested) == 0 {
|
||||
return nil, errors.New("player: no items to consume")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
next := ownedSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex,
|
||||
Items: append([]Item(nil), s.owned.Items...), Granted: make(map[string]bool, len(s.owned.Granted)), GrantItems: make(map[string][]uint64, len(s.owned.GrantItems))}
|
||||
for k, v := range s.owned.Granted {
|
||||
next.Granted[k] = v
|
||||
}
|
||||
for k, v := range s.owned.GrantItems {
|
||||
next.GrantItems[k] = append([]uint64(nil), v...)
|
||||
}
|
||||
for _, want := range requested {
|
||||
if want.InvenIndex == 0 || want.ID == 0 || want.Type == 0 || want.Count == 0 {
|
||||
return nil, errors.New("player: invalid item consumption")
|
||||
}
|
||||
found := -1
|
||||
for i, current := range next.Items {
|
||||
if current.InvenIndex == want.InvenIndex {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if found < 0 {
|
||||
return nil, fmt.Errorf("player: item %d is not mutable-owned", want.InvenIndex)
|
||||
}
|
||||
current := next.Items[found]
|
||||
if current.ID != want.ID || current.Type != want.Type || current.Count < want.Count {
|
||||
return nil, fmt.Errorf("player: item %d consumption mismatch", want.InvenIndex)
|
||||
}
|
||||
current.Count -= want.Count
|
||||
if current.Count == 0 {
|
||||
next.Items = append(next.Items[:found], next.Items[found+1:]...)
|
||||
} else {
|
||||
next.Items[found] = current
|
||||
}
|
||||
}
|
||||
granted := make([]Item, 0, len(refunds))
|
||||
for _, refund := range refunds {
|
||||
if refund.ID == 0 || refund.Count == 0 {
|
||||
return nil, errors.New("player: invalid growth refund")
|
||||
}
|
||||
item := Item{InvenIndex: next.NextIndex, ID: refund.ID, Type: 8, Count: refund.Count, TimeValue: uint64(time.Now().UnixMilli())}
|
||||
next.NextIndex++
|
||||
next.Items = append(next.Items, item)
|
||||
granted = append(granted, item)
|
||||
}
|
||||
if err := s.commitOwned(next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.owned = next
|
||||
return granted, nil
|
||||
}
|
||||
|
||||
// ItemWire builds ItemDBInfo for RewardDBInfoBundle and ItemInfoResponse.
|
||||
func ItemWire(item Item) []byte {
|
||||
var b []byte
|
||||
for _, f := range []struct {
|
||||
n int
|
||||
v uint64
|
||||
}{{1, item.InvenIndex}, {2, item.ID}, {3, item.Type}, {4, item.Count}, {5, item.KeepFlag}, {6, item.TimeValue}} {
|
||||
if f.v != 0 {
|
||||
b = wire.AppendVarint(b, f.n, f.v)
|
||||
}
|
||||
}
|
||||
if item.SortID != 0 {
|
||||
b = wire.AppendVarint(b, 9, item.SortID)
|
||||
}
|
||||
if item.UseCount != 0 {
|
||||
b = wire.AppendVarint(b, 10, item.UseCount)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestBattleRewardPersistsWithoutDuplicate(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "owned.json")
|
||||
starter := &Starter{Version: "2.35.10"}
|
||||
inv, err := OpenInventory(testStore(path), starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reward := []gamedata.BattleReward{{Type: 8, ID: 8, Count: 3}}
|
||||
items, err := inv.GrantOnce("pack21:monster1", reward)
|
||||
if err != nil || len(items) != 1 || items[0].Count != 3 {
|
||||
t.Fatalf("items=%+v err=%v", items, err)
|
||||
}
|
||||
inv, err = OpenInventory(testStore(path), starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err = inv.GrantOnce("pack21:monster1", reward)
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("duplicate grant=%+v err=%v", items, err)
|
||||
}
|
||||
code, response, ok, err := inv.Handle("/ItemInfo", wire.AppendVarint(nil, 1, 77))
|
||||
if err != nil || !ok || code != 21 {
|
||||
t.Fatalf("response=%d ok=%v err=%v", code, ok, err)
|
||||
}
|
||||
item, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatal("reward missing from inventory")
|
||||
}
|
||||
got, _, _ := wire.Varint(item, 2)
|
||||
if got != 8 {
|
||||
t.Fatalf("item id=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseRandomBoxPersistsExactStackAndRewardFromInstalledGameData(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
design, err := gamedata.LoadRandomBoxDesign(root, "20260910162539")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "items.json")
|
||||
inv, err := OpenInventory(testStore(path), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.AttachRandomBoxes(design); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
boxes, err := inv.GrantOnce("mail:box", []gamedata.BattleReward{{Type: 9, ID: 433302, Count: 100000}})
|
||||
if err != nil || len(boxes) != 1 {
|
||||
t.Fatalf("grant box = %+v, %v", boxes, err)
|
||||
}
|
||||
request := wire.AppendVarint(nil, 1, 73)
|
||||
request = wire.AppendVarint(request, 2, boxes[0].InvenIndex)
|
||||
request = wire.AppendVarint(request, 3, 100000)
|
||||
code, response, handled, err := inv.Handle("/UseRandomBox", request)
|
||||
if err != nil || !handled || code != 143 {
|
||||
t.Fatalf("UseRandomBox code=%d handled=%t err=%v", code, handled, err)
|
||||
}
|
||||
bundle, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("UseRandomBox reward bundle missing: found=%t err=%v", found, err)
|
||||
}
|
||||
item, found, err := wire.Bytes(bundle, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("UseRandomBox ItemDBInfo missing: found=%t err=%v", found, err)
|
||||
}
|
||||
id, _, _ := wire.Varint(item, 2)
|
||||
typ, _, _ := wire.Varint(item, 3)
|
||||
count, _, _ := wire.Varint(item, 4)
|
||||
if id != 704 || typ != 8 || count != 100000 {
|
||||
t.Fatalf("UseRandomBox response item id=%d type=%d count=%d", id, typ, count)
|
||||
}
|
||||
all := inv.All()
|
||||
if len(all) != 1 || all[0].ID != 704 || all[0].Type != 8 || all[0].Count != 100000 {
|
||||
t.Fatalf("UseRandomBox persisted inventory=%+v", all)
|
||||
}
|
||||
restored, err := OpenInventory(testStore(path), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all = restored.All()
|
||||
if len(all) != 1 || all[0].ID != 704 || all[0].Count != 100000 {
|
||||
t.Fatalf("UseRandomBox restored inventory=%+v", all)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
// PresetEquipmentBinding is one character's complete five-slot equipment
|
||||
// target used by the ordinary party preset service.
|
||||
type PresetEquipmentBinding struct {
|
||||
CharacterIndex uint64
|
||||
Equipment []uint64
|
||||
}
|
||||
|
||||
// ValidatePresetEquipment checks the complete fixed-width target without
|
||||
// mutating ownership. It lets a cross-domain PresetUse fail before any
|
||||
// character or deck state has been changed.
|
||||
func (s *EquipmentInventory) ValidatePresetEquipment(bindings []PresetEquipmentBinding) error {
|
||||
if len(bindings) == 0 {
|
||||
return errors.New("player: empty preset equipment bindings")
|
||||
}
|
||||
seenCharacters := make(map[uint64]bool, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
if binding.CharacterIndex == 0 || seenCharacters[binding.CharacterIndex] || len(binding.Equipment) != equipmentSlotCount {
|
||||
return errors.New("player: invalid preset equipment binding")
|
||||
}
|
||||
seenCharacters[binding.CharacterIndex] = true
|
||||
if s.characters == nil {
|
||||
return errors.New("player: preset character store unavailable")
|
||||
}
|
||||
if _, found := s.characters.Find(binding.CharacterIndex); !found {
|
||||
return fmt.Errorf("player: preset references unknown character %d", binding.CharacterIndex)
|
||||
}
|
||||
}
|
||||
// CharacterStore.Find may calculate maximum HP, and the production stat
|
||||
// calculator reads equipped items. It must run before taking this mutex or
|
||||
// preset validation deadlocks by trying to re-enter EquipmentInventory.
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.slots) == 0 {
|
||||
return errors.New("player: preset equipment slot design unavailable")
|
||||
}
|
||||
seenEquipment := make(map[uint64]bool)
|
||||
for _, binding := range bindings {
|
||||
for slot, index := range binding.Equipment {
|
||||
if index == 0 {
|
||||
continue
|
||||
}
|
||||
if seenEquipment[index] {
|
||||
return fmt.Errorf("player: preset repeats equipment %d", index)
|
||||
}
|
||||
seenEquipment[index] = true
|
||||
position := s.equipmentPositionLocked(index)
|
||||
if position < 0 {
|
||||
return fmt.Errorf("player: preset references unknown equipment %d", index)
|
||||
}
|
||||
item := s.owned.Equipment[position]
|
||||
if designedSlot, found := s.slots[item.ID]; !found || designedSlot != uint64(slot) {
|
||||
return fmt.Errorf("player: preset equipment %d does not belong in slot %d", index, slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyPresetEquipment applies the same authoritative state transition as
|
||||
// /EquipBatchUse without fabricating a second wire protocol implementation.
|
||||
func (s *EquipmentInventory) ApplyPresetEquipment(bindings []PresetEquipmentBinding) ([]Character, error) {
|
||||
if err := s.ValidatePresetEquipment(bindings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// PresetUse is a server-side atomic operation. Unlike the client-orchestrated
|
||||
// EquipPreset UI, it must also clear any previous owner of an equipment
|
||||
// instance in the same batch.
|
||||
s.mu.Lock()
|
||||
requested := make(map[uint64]bool, len(bindings))
|
||||
desired := make(map[uint64]bool)
|
||||
for _, binding := range bindings {
|
||||
requested[binding.CharacterIndex] = true
|
||||
for _, index := range binding.Equipment {
|
||||
desired[index] = index != 0
|
||||
}
|
||||
}
|
||||
additional := make(map[uint64][]uint64)
|
||||
for _, item := range s.owned.Equipment {
|
||||
if item.UseChar == 0 || requested[item.UseChar] || !desired[item.InvenIndex] {
|
||||
continue
|
||||
}
|
||||
if additional[item.UseChar] == nil {
|
||||
additional[item.UseChar] = make([]uint64, equipmentSlotCount)
|
||||
for _, equipped := range s.owned.Equipment {
|
||||
if equipped.UseChar == item.UseChar {
|
||||
if slot, ok := s.slots[equipped.ID]; ok && slot < equipmentSlotCount {
|
||||
additional[item.UseChar][slot] = equipped.InvenIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if slot, ok := s.slots[item.ID]; ok && slot < equipmentSlotCount {
|
||||
additional[item.UseChar][slot] = 0
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for character, equipment := range additional {
|
||||
bindings = append(bindings, PresetEquipmentBinding{CharacterIndex: character, Equipment: equipment})
|
||||
}
|
||||
sort.Slice(bindings, func(i, j int) bool { return bindings[i].CharacterIndex < bindings[j].CharacterIndex })
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
for _, binding := range bindings {
|
||||
entry := wire.AppendVarint(nil, 1, binding.CharacterIndex)
|
||||
for _, index := range binding.Equipment {
|
||||
entry = wire.AppendVarint(entry, 2, index)
|
||||
}
|
||||
request = wire.AppendBytes(request, 2, entry)
|
||||
}
|
||||
_, response, handled, err := s.batchUse(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !handled {
|
||||
return nil, errors.New("player: preset equipment batch was not handled")
|
||||
}
|
||||
var result []Character
|
||||
if err := wire.Walk(response, func(field wire.Field) error {
|
||||
if field.Number != 1 || field.Type != 2 {
|
||||
return nil
|
||||
}
|
||||
index, found, err := wire.Varint(field.Value, 1)
|
||||
if err != nil || !found || index == 0 {
|
||||
return errors.New("player: malformed preset equipment character response")
|
||||
}
|
||||
character, found := s.characters.Find(index)
|
||||
if !found {
|
||||
return fmt.Errorf("player: preset equipment character %d disappeared", index)
|
||||
}
|
||||
result = append(result, character)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ApplyPresetCostumes changes the currently selected costume for each listed
|
||||
// character and persists both seeded and collection-owned character records.
|
||||
func (s *CharacterStore) ApplyPresetCostumes(assignments map[uint64]uint64) ([]Character, error) {
|
||||
if len(assignments) == 0 {
|
||||
return nil, errors.New("player: empty preset costume assignments")
|
||||
}
|
||||
s.mu.Lock()
|
||||
base := append([]Character(nil), s.characters...)
|
||||
collection := s.collection
|
||||
s.mu.Unlock()
|
||||
if collection == nil {
|
||||
return nil, errors.New("player: preset costumes require collection store")
|
||||
}
|
||||
collectionCharacters := collection.Characters()
|
||||
seenCostumes := make(map[uint64]bool)
|
||||
updated := make(map[uint64]Character, len(assignments))
|
||||
for characterIndex, costumeIndex := range assignments {
|
||||
var current Character
|
||||
found := false
|
||||
for _, character := range base {
|
||||
if character.InvenIndex == characterIndex {
|
||||
current, found = character, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
for _, character := range collectionCharacters {
|
||||
if character.InvenIndex == characterIndex {
|
||||
current, found = character, true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("player: preset references unknown character %d", characterIndex)
|
||||
}
|
||||
if costumeIndex == 0 {
|
||||
current.CostumeID = 0
|
||||
current.UseCostume = 0
|
||||
} else {
|
||||
if seenCostumes[costumeIndex] {
|
||||
return nil, fmt.Errorf("player: preset repeats costume %d", costumeIndex)
|
||||
}
|
||||
costume, found := collection.CostumeByIndex(costumeIndex)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("player: preset references unknown costume %d", costumeIndex)
|
||||
}
|
||||
seenCostumes[costumeIndex] = true
|
||||
current.CostumeID = costume.ID
|
||||
current.UseCostume = costumeIndex
|
||||
}
|
||||
updated[characterIndex] = current
|
||||
}
|
||||
|
||||
nextBase := append([]Character(nil), base...)
|
||||
baseChanged := false
|
||||
for i := range nextBase {
|
||||
if character, ok := updated[nextBase[i].InvenIndex]; ok {
|
||||
nextBase[i] = character
|
||||
delete(updated, character.InvenIndex)
|
||||
baseChanged = true
|
||||
}
|
||||
}
|
||||
if baseChanged {
|
||||
s.mu.Lock()
|
||||
if err := s.persist(nextBase); err != nil {
|
||||
s.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
s.characters = nextBase
|
||||
s.mu.Unlock()
|
||||
}
|
||||
for index, character := range updated {
|
||||
old, found := collection.FindCharacter(index)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("player: preset collection character %d disappeared", index)
|
||||
}
|
||||
if err := collection.UpdateCharacter(old.ID, character); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
indices := make([]uint64, 0, len(assignments))
|
||||
for index := range assignments {
|
||||
indices = append(indices, index)
|
||||
}
|
||||
sort.Slice(indices, func(i, j int) bool { return indices[i] < indices[j] })
|
||||
result := make([]Character, 0, len(indices))
|
||||
for _, index := range indices {
|
||||
character, found := s.Find(index)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("player: preset character %d disappeared after update", index)
|
||||
}
|
||||
result = append(result, character)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidatePresetEquipmentDoesNotHoldEquipmentLockDuringCharacterStats(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 100, ID: 350, Level: 1}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipment, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := equipment.AttachSlots(map[uint64]uint64{10: 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := equipment.AttachCharacters(characters); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned, err := equipment.GrantOnce("preset-deadlock", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := characters.AttachMaxHealth(func(Character) (uint64, error) {
|
||||
// This mirrors the production stat calculator's dependency on the
|
||||
// equipment inventory. Validation must not hold equipment.mu here.
|
||||
if len(equipment.All()) == 0 {
|
||||
return 0, errors.New("missing equipment")
|
||||
}
|
||||
return 100, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- equipment.ValidatePresetEquipment([]PresetEquipmentBinding{{
|
||||
CharacterIndex: 100,
|
||||
Equipment: []uint64{owned.InvenIndex, 0, 0, 0, 0},
|
||||
}})
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("preset equipment validation deadlocked through character stat calculation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Package player owns the initial, mutable inventory/character snapshot used
|
||||
// by the local account. GameData defines what each ID means; this file holds
|
||||
// only the new player's ownership and progress.
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"bd2server/internal/server/versionconfig"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type Item struct {
|
||||
InvenIndex uint64 `json:"inven_index"`
|
||||
ID uint64 `json:"id"`
|
||||
Type uint64 `json:"type"`
|
||||
Count uint64 `json:"count"`
|
||||
KeepFlag uint64 `json:"keep_flag,omitempty"`
|
||||
TimeValue uint64 `json:"time_value,omitempty"`
|
||||
Pictorialbook *Pictorial `json:"pictorialbook,omitempty"`
|
||||
SortID uint64 `json:"sort_id,omitempty"`
|
||||
UseCount uint64 `json:"use_count,omitempty"`
|
||||
}
|
||||
|
||||
type Costume struct {
|
||||
InvenIndex uint64 `json:"inven_index"`
|
||||
ID uint64 `json:"id"`
|
||||
Level uint64 `json:"level,omitempty"`
|
||||
UseChar uint64 `json:"use_char,omitempty"`
|
||||
SortID uint64 `json:"sort_id,omitempty"`
|
||||
PotentialIDs []uint64 `json:"-"`
|
||||
DesignID uint64 `json:"design_id,omitempty"`
|
||||
BurstLevel uint64 `json:"burst_level,omitempty"`
|
||||
TimeValue uint64 `json:"time_value,omitempty"`
|
||||
Pictorialbook []Pictorial `json:"pictorialbook,omitempty"`
|
||||
}
|
||||
|
||||
type Pictorial struct {
|
||||
ID uint64 `json:"id"`
|
||||
GroupID uint64 `json:"group_id"`
|
||||
}
|
||||
|
||||
type Character struct {
|
||||
InvenIndex uint64 `json:"inven_index"`
|
||||
ID uint64 `json:"id"`
|
||||
HP uint64 `json:"hp,omitempty"`
|
||||
Level uint64 `json:"level,omitempty"`
|
||||
CostumeID uint64 `json:"costume_id,omitempty"`
|
||||
Exp uint64 `json:"exp,omitempty"`
|
||||
UseCostume uint64 `json:"use_costume,omitempty"`
|
||||
TalentLevel uint64 `json:"talent_level,omitempty"`
|
||||
TalentExp uint64 `json:"talent_exp,omitempty"`
|
||||
SolidarityReward uint64 `json:"solidarity_reward,omitempty"`
|
||||
ExpiryTime uint64 `json:"expiry_time,omitempty"`
|
||||
ConnectPotentialCostume uint64 `json:"connect_potential_costume,omitempty"`
|
||||
Pictorialbook []Pictorial `json:"pictorialbook,omitempty"`
|
||||
}
|
||||
|
||||
type Starter struct {
|
||||
Version string `json:"version"`
|
||||
Items []Item `json:"items"`
|
||||
Costumes []Costume `json:"costumes"`
|
||||
Characters []Character `json:"characters"`
|
||||
Pictorialbook []Pictorial `json:"pictorialbook,omitempty"`
|
||||
FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Starter, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("player: read starter: %w", err)
|
||||
}
|
||||
var starter Starter
|
||||
if err := json.Unmarshal(data, &starter); err != nil {
|
||||
return nil, fmt.Errorf("player: decode starter: %w", err)
|
||||
}
|
||||
if err := starter.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &starter, nil
|
||||
}
|
||||
|
||||
func (s *Starter) Validate() error {
|
||||
if s == nil || s.Version != versionconfig.State() {
|
||||
return errors.New("player: wrong starter version")
|
||||
}
|
||||
for _, item := range s.Items {
|
||||
if item.ID == 0 || item.Count == 0 {
|
||||
return errors.New("player: invalid item")
|
||||
}
|
||||
}
|
||||
for _, costume := range s.Costumes {
|
||||
if costume.ID == 0 || costume.InvenIndex == 0 {
|
||||
return errors.New("player: invalid costume")
|
||||
}
|
||||
}
|
||||
for _, character := range s.Characters {
|
||||
if character.ID == 0 || character.InvenIndex == 0 {
|
||||
return errors.New("player: invalid character")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Starter) Write(path string) error {
|
||||
if err := s.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
func add(dst []byte, field int, value uint64) []byte {
|
||||
if value != 0 {
|
||||
return wire.AppendVarint(dst, field, value)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func (s *Starter) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
var code int
|
||||
var response []byte
|
||||
switch path {
|
||||
case "/ItemInfo":
|
||||
code = 21
|
||||
for _, entry := range s.Items {
|
||||
response = wire.AppendBytes(response, 1, ItemWire(entry))
|
||||
}
|
||||
case "/CostumeInfo":
|
||||
code = 40
|
||||
for _, entry := range s.Costumes {
|
||||
response = wire.AppendBytes(response, 1, CostumeWire(entry))
|
||||
}
|
||||
case "/CharInfo":
|
||||
code = 9
|
||||
for _, entry := range s.Characters {
|
||||
var character []byte
|
||||
character = add(character, 1, entry.InvenIndex)
|
||||
character = add(character, 2, entry.ID)
|
||||
character = add(character, 3, entry.HP)
|
||||
character = add(character, 4, entry.Level)
|
||||
character = add(character, 5, entry.CostumeID)
|
||||
character = add(character, 6, entry.Exp)
|
||||
character = add(character, 7, entry.UseCostume)
|
||||
character = add(character, 8, entry.TalentLevel)
|
||||
character = add(character, 9, entry.TalentExp)
|
||||
character = add(character, 10, entry.SolidarityReward)
|
||||
character = add(character, 11, entry.ExpiryTime)
|
||||
character = add(character, 13, entry.ConnectPotentialCostume)
|
||||
response = wire.AppendBytes(response, 1, character)
|
||||
}
|
||||
response = add(response, 2, s.FieldCharControlDeckType)
|
||||
default:
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
seq, present, err := wire.Varint(request, 1)
|
||||
if err != nil || !present || seq == 0 {
|
||||
return 0, nil, true, fmt.Errorf("player: %s invalid sequence", path)
|
||||
}
|
||||
return code, response, true, nil
|
||||
}
|
||||
|
||||
func CostumeWire(entry Costume) []byte {
|
||||
var costume []byte
|
||||
costume = add(costume, 1, entry.InvenIndex)
|
||||
costume = add(costume, 2, entry.ID)
|
||||
costume = add(costume, 3, entry.Level)
|
||||
costume = add(costume, 4, entry.UseChar)
|
||||
for _, p := range entry.Pictorialbook {
|
||||
book := add(add(nil, 1, p.ID), 2, p.GroupID)
|
||||
costume = wire.AppendBytes(costume, 5, book)
|
||||
}
|
||||
costume = add(costume, 6, entry.SortID)
|
||||
for _, id := range entry.PotentialIDs {
|
||||
costume = wire.AppendVarint(costume, 8, id)
|
||||
}
|
||||
costume = add(costume, 9, entry.DesignID)
|
||||
costume = add(costume, 10, entry.BurstLevel)
|
||||
costume = add(costume, 12, entry.TimeValue)
|
||||
return costume
|
||||
}
|
||||
|
||||
func decodeVarints(proto []byte, fields map[int]*uint64) error {
|
||||
return wire.Walk(proto, func(field wire.Field) error {
|
||||
value, known := fields[field.Number]
|
||||
if !known {
|
||||
return fmt.Errorf("player: unsupported starter field %d", field.Number)
|
||||
}
|
||||
if field.Type != 0 {
|
||||
return fmt.Errorf("player: starter field %d wire type %d", field.Number, field.Type)
|
||||
}
|
||||
*value, _ = binary.Uvarint(field.Value)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/fixture"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestStarterCanAnswerWithoutCapture(t *testing.T) {
|
||||
seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "starter_player.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, code := range map[string]int{"/ItemInfo": 21, "/CostumeInfo": 40, "/CharInfo": 9} {
|
||||
actual, proto, ok, err := seed.Handle(path, wire.AppendVarint(nil, 1, 900001))
|
||||
if err != nil || !ok || actual != code || len(proto) == 0 {
|
||||
t.Fatalf("%s response: code=%d len=%d ok=%v err=%v", path, actual, len(proto), ok, err)
|
||||
}
|
||||
}
|
||||
if _, _, ok, err := seed.Handle("/NotImplemented", nil); ok || err != nil {
|
||||
t.Fatal("accepted unknown endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarterMatchesInitialAccountSample(t *testing.T) {
|
||||
root := filepath.Join("..", "..", "..", "..", "data", "capture", "2.34.13", "20260920-003254")
|
||||
set, err := fixture.Load(root)
|
||||
if err != nil {
|
||||
t.Skipf("optional capture unavailable: %v", err)
|
||||
}
|
||||
key, err := set.SessionKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch, err := set.BatchResponse(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, recorded, err := fixture.DecodeBatch(batch, []byte(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "starter_player.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"/ItemInfo", "/CostumeInfo", "/CharInfo"} {
|
||||
_, proto, ok, err := seed.Handle(path, wire.AppendVarint(nil, 1, 900001))
|
||||
if err != nil || !ok || !bytes.Equal(proto, recorded[path]) {
|
||||
t.Errorf("%s parsed player starter differs from source: got=%x want=%x err=%v", path, proto, recorded[path], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
)
|
||||
|
||||
var testStores sync.Map
|
||||
|
||||
func testStore(path string) stateio.Store {
|
||||
dir := filepath.Dir(path)
|
||||
store, _ := testStores.LoadOrStore(dir, stateio.NewMemory())
|
||||
return store.(stateio.Store)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/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/server/gamedata"
|
||||
"bd2server/internal/server/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.35.10"})
|
||||
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.35.10"})
|
||||
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.35.10"})
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/versionconfig"
|
||||
)
|
||||
|
||||
// Currency uses the UserDBInfo currency fields: type 3 is free jewelry and
|
||||
// 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"`
|
||||
EquipMileageExchangeGage uint64 `json:"equip_mileage_exchange_gage"`
|
||||
}
|
||||
|
||||
type walletSnapshot struct {
|
||||
Version string `json:"version"`
|
||||
Currency
|
||||
Granted map[string]bool `json:"-"`
|
||||
Spent map[string]bool `json:"-"`
|
||||
}
|
||||
|
||||
type Wallet struct {
|
||||
mu sync.Mutex
|
||||
store stateio.AtomicEntryStore
|
||||
state walletSnapshot
|
||||
}
|
||||
|
||||
func OpenWallet(store stateio.Store, initial Currency) (*Wallet, error) {
|
||||
entries, ok := store.(stateio.AtomicEntryStore)
|
||||
if !ok {
|
||||
return nil, errors.New("player: wallet store is nil")
|
||||
}
|
||||
s := &Wallet{store: entries, state: walletSnapshot{
|
||||
Version: versionconfig.State(), Currency: initial, Granted: map[string]bool{}, Spent: map[string]bool{},
|
||||
}}
|
||||
b, err := store.Load("wallet")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("player: load wallet: %w", err)
|
||||
}
|
||||
if b != 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
|
||||
if err := json.Unmarshal(b, &shape); err != nil {
|
||||
return nil, errors.New("player: malformed wallet state")
|
||||
}
|
||||
if _, ok := shape["granted"]; ok {
|
||||
return nil, errors.New("player: wallet grant ledger must use entries")
|
||||
}
|
||||
if _, ok := shape["spent"]; ok {
|
||||
return nil, errors.New("player: wallet spend ledger must use entries")
|
||||
}
|
||||
if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != versionconfig.State() {
|
||||
return nil, errors.New("player: malformed wallet state")
|
||||
}
|
||||
} else if err := stateio.RequireNoEntries(entries, "wallet", "granted", "spent"); err != nil {
|
||||
return nil, fmt.Errorf("player: invalid wallet storage: %w", err)
|
||||
}
|
||||
s.state.Granted, err = loadBoolEntries(entries, "wallet", "granted")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.state.Spent, err = loadBoolEntries(entries, "wallet", "spent")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Wallet) EnsurePersisted() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
b, err := s.store.Load("wallet")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b != nil {
|
||||
return nil
|
||||
}
|
||||
return s.commit(cloneWallet(s.state))
|
||||
}
|
||||
|
||||
func (s *Wallet) Snapshot() Currency {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.state.Currency
|
||||
}
|
||||
|
||||
func (s *Wallet) WasGranted(identity string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.state.Granted[identity]
|
||||
}
|
||||
|
||||
func (s *Wallet) CanSpendFreeJewelry(amount uint64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return amount > 0 && s.state.FreeJewelry >= amount
|
||||
}
|
||||
|
||||
func (s *Wallet) CanSpendGold(amount uint64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return amount > 0 && s.state.Gold >= amount
|
||||
}
|
||||
|
||||
// SpendGoldOnce covers the currency part of a GameData-defined class-up.
|
||||
// Its identity is the old character instance/stage so an interrupted request
|
||||
// cannot charge the same promotion a second time.
|
||||
func (s *Wallet) SpendGoldOnce(identity string, amount uint64) (Currency, error) {
|
||||
if identity == "" || amount == 0 {
|
||||
return Currency{}, errors.New("player: invalid gold spend")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.Spent[identity] {
|
||||
return s.state.Currency, nil
|
||||
}
|
||||
if s.state.Gold < amount {
|
||||
return Currency{}, errors.New("player: insufficient gold")
|
||||
}
|
||||
next := cloneWallet(s.state)
|
||||
next.Gold -= 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) WasSpent(identity string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.state.Spent[identity]
|
||||
}
|
||||
|
||||
func (s *Wallet) SpendFreeJewelryOnce(identity string, amount uint64) (Currency, error) {
|
||||
return s.spendJewelryOnce(identity, amount, false)
|
||||
}
|
||||
|
||||
func (s *Wallet) SpendJewelryOnce(identity string, amount uint64) (Currency, error) {
|
||||
return s.spendJewelryOnce(identity, amount, true)
|
||||
}
|
||||
|
||||
func (s *Wallet) spendJewelryOnce(identity string, amount uint64, paid bool) (Currency, error) {
|
||||
if identity == "" || amount == 0 {
|
||||
return Currency{}, errors.New("player: invalid wallet spend")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.Spent[identity] {
|
||||
return s.state.Currency, nil
|
||||
}
|
||||
next := cloneWallet(s.state)
|
||||
if paid {
|
||||
if next.Jewelry < amount {
|
||||
return Currency{}, errors.New("player: insufficient paid jewelry")
|
||||
}
|
||||
next.Jewelry -= amount
|
||||
} else {
|
||||
if next.FreeJewelry < amount {
|
||||
return Currency{}, errors.New("player: insufficient free jewelry")
|
||||
}
|
||||
next.FreeJewelry -= amount
|
||||
}
|
||||
next.Spent[identity] = true
|
||||
if err := s.commit(next, entry("spent", identity, []byte("true"))...); err != nil {
|
||||
return Currency{}, err
|
||||
}
|
||||
return next.Currency, nil
|
||||
}
|
||||
|
||||
// Currencies implements account.CurrencyProvider.
|
||||
func (s *Wallet) Currencies() (gold, freeJewelry, jewelry, mileage uint64) {
|
||||
c := s.Snapshot()
|
||||
return c.Gold, c.FreeJewelry, c.Jewelry, c.Mileage
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// RecordEquipmentSmelting converts the actually consumed refinement material
|
||||
// into the official residual gauge and type-68 mileage currency.
|
||||
func (s *Wallet) RecordEquipmentSmelting(materialCount, threshold, rewardCount uint64) (Currency, uint64, error) {
|
||||
if materialCount == 0 || threshold == 0 || rewardCount == 0 {
|
||||
return Currency{}, 0, errors.New("player: invalid equipment smelting mileage")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.EquipMileageExchangeGage >= threshold || math.MaxUint64-s.state.EquipMileageExchangeGage < materialCount {
|
||||
return Currency{}, 0, errors.New("player: invalid equipment smelting gauge")
|
||||
}
|
||||
next := cloneWallet(s.state)
|
||||
total := next.EquipMileageExchangeGage + materialCount
|
||||
exchanges := total / threshold
|
||||
earned := exchanges * rewardCount
|
||||
if exchanges != 0 && earned/exchanges != rewardCount || math.MaxUint64-next.EquipMileage < earned {
|
||||
return Currency{}, 0, errors.New("player: equipment mileage overflow")
|
||||
}
|
||||
next.EquipMileageExchangeGage = total % threshold
|
||||
next.EquipMileage += earned
|
||||
if err := s.commit(next); err != nil {
|
||||
return Currency{}, 0, err
|
||||
}
|
||||
return next.Currency, earned, nil
|
||||
}
|
||||
|
||||
// GrantMileageOnce persists the type-20 currency produced when a duplicate
|
||||
// costume is drawn after +5. The gacha grant identity makes recovery after a
|
||||
// collection/wallet split commit safe and idempotent.
|
||||
func (s *Wallet) GrantMileageOnce(identity string, amount uint64) (Currency, error) {
|
||||
if identity == "" || amount == 0 {
|
||||
return Currency{}, errors.New("player: invalid mileage grant")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.Granted[identity] {
|
||||
return s.state.Currency, nil
|
||||
}
|
||||
next := cloneWallet(s.state)
|
||||
if math.MaxUint64-next.Mileage < amount {
|
||||
return Currency{}, errors.New("player: mileage overflow")
|
||||
}
|
||||
next.Mileage += amount
|
||||
next.Granted[identity] = true
|
||||
if err := s.commit(next, entry("granted", identity, []byte("true"))...); err != nil {
|
||||
return Currency{}, err
|
||||
}
|
||||
return next.Currency, nil
|
||||
}
|
||||
|
||||
func (s *Wallet) GrantHopePowderOnce(identity string, amount uint64) (Currency, error) {
|
||||
if identity == "" || amount == 0 {
|
||||
return Currency{}, errors.New("player: invalid hope powder grant")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.Granted[identity] {
|
||||
return s.state.Currency, nil
|
||||
}
|
||||
next := cloneWallet(s.state)
|
||||
if math.MaxUint64-next.HopePowder < amount {
|
||||
return Currency{}, errors.New("player: hope powder overflow")
|
||||
}
|
||||
next.HopePowder += amount
|
||||
next.Granted[identity] = true
|
||||
if err := s.commit(next, entry("granted", identity, []byte("true"))...); err != nil {
|
||||
return Currency{}, err
|
||||
}
|
||||
return next.Currency, nil
|
||||
}
|
||||
|
||||
// GrantQuestOnce applies only currency rewards and is idempotent by the same
|
||||
// quest identity used by the entity stores. Unknown reward types are ignored;
|
||||
// callers dispatch those to their owning inventory domain.
|
||||
func (s *Wallet) GrantQuestOnce(identity string, rewards []gamedata.Reward) (Currency, error) {
|
||||
if identity == "" {
|
||||
return Currency{}, errors.New("player: missing wallet grant identity")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.Granted[identity] {
|
||||
return s.state.Currency, nil
|
||||
}
|
||||
next := cloneWallet(s.state)
|
||||
for _, reward := range rewards {
|
||||
switch reward.Type {
|
||||
case 3:
|
||||
if math.MaxUint64-next.FreeJewelry < reward.Count {
|
||||
return Currency{}, errors.New("player: free jewelry overflow")
|
||||
}
|
||||
next.FreeJewelry += reward.Count
|
||||
case 4:
|
||||
if math.MaxUint64-next.Gold < reward.Count {
|
||||
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
|
||||
if err := s.commit(next, entry("granted", identity, []byte("true"))...); err != nil {
|
||||
return Currency{}, err
|
||||
}
|
||||
return next.Currency, nil
|
||||
}
|
||||
|
||||
func cloneWallet(in walletSnapshot) walletSnapshot {
|
||||
out := walletSnapshot{Version: in.Version, Currency: in.Currency, Granted: make(map[string]bool, len(in.Granted)), Spent: make(map[string]bool, len(in.Spent))}
|
||||
for key, value := range in.Granted {
|
||||
out.Granted[key] = value
|
||||
}
|
||||
for key, value := range in.Spent {
|
||||
out.Spent[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Wallet) commit(next walletSnapshot, changes ...stateio.EntryMutation) error {
|
||||
b, err := json.Marshal(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.SaveWithEntries("wallet", b, changes); err != nil {
|
||||
return fmt.Errorf("player: persist wallet: %w", err)
|
||||
}
|
||||
s.state = next
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
)
|
||||
|
||||
func TestWalletGrantPersistsAndIsIdempotent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "wallet.json")
|
||||
wallet, err := OpenWallet(testStore(path), Currency{Gold: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
wallet, err = OpenWallet(testStore(path), Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := wallet.GrantQuestOnce("quest:1", rewards); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := wallet.Snapshot()
|
||||
if got.Gold != 1600 || got.FreeJewelry != 70 || got.Catalyst != 45 {
|
||||
t.Fatalf("wallet=%+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user