feat: split client tooling and add OAuth server login

This commit is contained in:
2026-09-30 17:32:14 +08:00
parent 7cb5e36c2a
commit 2d054f6b72
230 changed files with 9875 additions and 853 deletions
+300
View File
@@ -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())
}
}
+998
View File
@@ -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")
}
}
+43
View File
@@ -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)
}
}
+725
View File
@@ -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)
}
}
+224
View File
@@ -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
}
+498
View File
@@ -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
}
+118
View File
@@ -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")
}
}
+431
View File
@@ -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")
}
})
}
}
+485
View File
@@ -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
}
+100
View File
@@ -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)
}
}
+242
View File
@@ -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")
}
}
+202
View File
@@ -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
})
}
+56
View File
@@ -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)
}
}
}
+16
View File
@@ -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)
}
+150
View File
@@ -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)
}
}
+361
View File
@@ -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
}
+31
View File
@@ -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)
}
}