DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,795 @@
|
||||
package collectbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
stateValue = "state_json"
|
||||
MetadataKey = "collectbox_state_v1"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOwnerUnavailable = errors.New("collect box owner unavailable")
|
||||
ErrCharacterRequired = errors.New("collect box character required")
|
||||
ErrInventoryMissing = errors.New("collect box inventory missing")
|
||||
ErrDefinitionInvalid = errors.New("collect box definition invalid")
|
||||
ErrSourceMissing = errors.New("collect box source item missing")
|
||||
ErrSourceMismatch = errors.New("collect box source item mismatch")
|
||||
ErrBoxItemAlreadyStored = errors.New("collect box item already stored")
|
||||
ErrBoxItemMissing = errors.New("collect box item missing")
|
||||
ErrInventoryFull = errors.New("collect box inventory full")
|
||||
ErrReturnPlacementInvalid = errors.New("collect box return placement invalid")
|
||||
)
|
||||
|
||||
// Definition is the PVF-owned configuration for one collection box.
|
||||
type Definition struct {
|
||||
Index byte
|
||||
BaseExpirationDate string
|
||||
MaxExpirationDate string
|
||||
ExtendItemID uint32
|
||||
ExtendItemCount int64
|
||||
Slots []Slot
|
||||
}
|
||||
|
||||
type Slot struct {
|
||||
ItemID uint32
|
||||
Count int64
|
||||
BuffType int64
|
||||
Bonus Bonus
|
||||
}
|
||||
|
||||
type Bonus struct {
|
||||
PhysicalAttack int64
|
||||
PhysicalDefense int64
|
||||
MagicalAttack int64
|
||||
MagicalDefense int64
|
||||
IndependentAttack int64
|
||||
}
|
||||
|
||||
func (b *Bonus) Add(other Bonus) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.PhysicalAttack += other.PhysicalAttack
|
||||
b.PhysicalDefense += other.PhysicalDefense
|
||||
b.MagicalAttack += other.MagicalAttack
|
||||
b.MagicalDefense += other.MagicalDefense
|
||||
b.IndependentAttack += other.IndependentAttack
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Boxes map[string]map[string]uint32 `json:"boxes,omitempty"`
|
||||
Expiration map[string]time.Time `json:"expiration,omitempty"`
|
||||
// Selected records the box each character is currently looking at. The
|
||||
// native collection box applies the attributes of the *selected* box, so a
|
||||
// login or actor-generation publication has to restore this instead of a
|
||||
// default. The account-metadata copy of this state is shared by every
|
||||
// character of the account, which is why the map is keyed by character ID.
|
||||
Selected map[string]byte `json:"selected,omitempty"`
|
||||
}
|
||||
|
||||
// SelectedBox returns the recorded box for one character, or zero when the
|
||||
// character never picked one.
|
||||
func (s State) SelectedBox(characterID string) byte {
|
||||
index, ok := s.Selected[strings.TrimSpace(characterID)]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// SetSelectedBox records the box for one character; zero clears the record.
|
||||
func (s *State) SetSelectedBox(characterID string, index byte) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
if characterID == "" {
|
||||
return
|
||||
}
|
||||
if index == 0 {
|
||||
delete(s.Selected, characterID)
|
||||
return
|
||||
}
|
||||
if s.Selected == nil {
|
||||
s.Selected = make(map[string]byte)
|
||||
}
|
||||
s.Selected[characterID] = index
|
||||
}
|
||||
|
||||
type InsertCommand struct {
|
||||
CharacterID string
|
||||
Definition Definition
|
||||
SourceSlot int16
|
||||
ItemID uint32
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type InsertResult struct {
|
||||
BoxIndex byte
|
||||
BoxSlot int
|
||||
ItemID uint32
|
||||
RequiredCount int64
|
||||
SourceSlot int16
|
||||
SourceRemainingCount int64
|
||||
SourceRemoved bool
|
||||
}
|
||||
|
||||
type RemoveCommand struct {
|
||||
CharacterID string
|
||||
Definition Definition
|
||||
ItemID uint32
|
||||
ReturnPlacement ReturnPlacement
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ReturnPlacement is the active-PVF main-inventory page for an item that is
|
||||
// taken back out of the collection box. The box state intentionally stores
|
||||
// only item IDs, so the bridge resolves this placement before the durable
|
||||
// transaction. Keeping it explicit prevents a material/jewel from falling
|
||||
// back into the equipment page merely because that page has a free slot.
|
||||
type ReturnPlacement struct {
|
||||
SlotStart int16
|
||||
SlotEnd int16
|
||||
StackLimit int64
|
||||
}
|
||||
|
||||
type RemoveResult struct {
|
||||
BoxIndex byte
|
||||
BoxSlot int
|
||||
ItemID uint32
|
||||
RequiredCount int64
|
||||
TargetSlot int16
|
||||
TargetStack dnfrepo.ItemStack
|
||||
}
|
||||
|
||||
type ExtendCommand struct {
|
||||
CharacterID string
|
||||
Definition Definition
|
||||
ItemID uint32
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ExtendResult struct {
|
||||
BoxIndex byte
|
||||
ItemID uint32
|
||||
RequiredCount int64
|
||||
SourceSlot int16
|
||||
SourceRemaining int64
|
||||
SourceRemoved bool
|
||||
Expiration time.Time
|
||||
}
|
||||
|
||||
type Owner struct {
|
||||
settlements dnfrepo.CharacterSettlementUnitOfWork
|
||||
}
|
||||
|
||||
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
|
||||
if repositories.CharacterSettlement == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
return &Owner{settlements: repositories.CharacterSettlement}, nil
|
||||
}
|
||||
|
||||
func Scope(characterID string) string {
|
||||
return "character:" + strings.TrimSpace(characterID) + ":collectbox"
|
||||
}
|
||||
|
||||
func LoadState(ctx context.Context, repository dnfrepo.SettingsRepository, characterID string) (State, error) {
|
||||
if repository == nil || strings.TrimSpace(characterID) == "" {
|
||||
return State{}, ErrOwnerUnavailable
|
||||
}
|
||||
record, found, err := repository.Load(ctx, Scope(characterID))
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
if !found {
|
||||
return State{Boxes: make(map[string]map[string]uint32), Expiration: make(map[string]time.Time)}, nil
|
||||
}
|
||||
raw := strings.TrimSpace(record.Values[stateValue])
|
||||
if raw == "" {
|
||||
return State{Boxes: make(map[string]map[string]uint32), Expiration: make(map[string]time.Time)}, nil
|
||||
}
|
||||
return DecodeState(raw)
|
||||
}
|
||||
|
||||
func SaveState(ctx context.Context, repository dnfrepo.SettingsRepository, characterID string, state State, updatedAt time.Time) error {
|
||||
if repository == nil || strings.TrimSpace(characterID) == "" {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
raw, err := EncodeState(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = time.Now().UTC()
|
||||
}
|
||||
return repository.Save(ctx, dnfrepo.SettingsRecord{
|
||||
Scope: Scope(characterID),
|
||||
Values: map[string]string{stateValue: string(raw)},
|
||||
UpdatedAt: updatedAt.UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
func DecodeState(raw string) (State, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return normalizeState(State{}), nil
|
||||
}
|
||||
var state State
|
||||
if err := json.Unmarshal([]byte(raw), &state); err != nil {
|
||||
return State{}, fmt.Errorf("decode collect box state: %w", err)
|
||||
}
|
||||
return normalizeState(state), nil
|
||||
}
|
||||
|
||||
func EncodeState(state State) (string, error) {
|
||||
raw, err := json.Marshal(normalizeState(state))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func LoadAccountState(ctx context.Context, repository dnfrepo.AccountRepository, accountID string) (State, bool, error) {
|
||||
if repository == nil || strings.TrimSpace(accountID) == "" {
|
||||
return State{}, false, ErrOwnerUnavailable
|
||||
}
|
||||
account, found, err := repository.Load(ctx, strings.TrimSpace(accountID))
|
||||
if err != nil || !found {
|
||||
return State{}, false, err
|
||||
}
|
||||
raw := strings.TrimSpace(account.Metadata[MetadataKey])
|
||||
if raw == "" {
|
||||
return normalizeState(State{}), false, nil
|
||||
}
|
||||
state, err := DecodeState(raw)
|
||||
return state, true, err
|
||||
}
|
||||
|
||||
func Snapshot(definition Definition, state State) []uint32 {
|
||||
if !validDefinition(definition) {
|
||||
return nil
|
||||
}
|
||||
box := state.Boxes[strconv.Itoa(int(definition.Index))]
|
||||
items := make([]uint32, 0, len(box))
|
||||
for slot := range definition.Slots {
|
||||
if itemID := box[strconv.Itoa(slot)]; itemID != 0 {
|
||||
items = append(items, itemID)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (o *Owner) Insert(ctx context.Context, command InsertCommand) (InsertResult, error) {
|
||||
if o == nil || o.settlements == nil {
|
||||
return InsertResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if characterID == "" {
|
||||
return InsertResult{}, ErrCharacterRequired
|
||||
}
|
||||
slotIndex, requirement, err := resolveSlot(command.Definition, command.ItemID)
|
||||
if err != nil {
|
||||
return InsertResult{}, err
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return InsertResult{}, err
|
||||
}
|
||||
if command.UpdatedAt.IsZero() {
|
||||
command.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
var result InsertResult
|
||||
err = o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
if tx.Inventory == nil || tx.Settings == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
inventory, found, loadErr := tx.Inventory.Load(ctx, characterID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
|
||||
return ErrInventoryMissing
|
||||
}
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
if inventory.Slots == nil {
|
||||
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
||||
}
|
||||
state, account, stateErr := loadStateForSettlement(ctx, tx, characterID)
|
||||
if stateErr != nil {
|
||||
return stateErr
|
||||
}
|
||||
box := ensureBox(&state, command.Definition.Index)
|
||||
slotKey := strconv.Itoa(slotIndex)
|
||||
if box[slotKey] != 0 {
|
||||
return ErrBoxItemAlreadyStored
|
||||
}
|
||||
for _, stored := range box {
|
||||
if stored == command.ItemID {
|
||||
return ErrBoxItemAlreadyStored
|
||||
}
|
||||
}
|
||||
sourceKey := inventoryKey(dnfrepo.MainInventoryListType, command.SourceSlot)
|
||||
source, ok := inventory.Slots[sourceKey]
|
||||
if !ok || source.ItemID <= 0 {
|
||||
return fmt.Errorf("%w: slot=%d", ErrSourceMissing, command.SourceSlot)
|
||||
}
|
||||
if source.ItemID != int64(command.ItemID) {
|
||||
return fmt.Errorf("%w: want=%d got=%d", ErrSourceMismatch, command.ItemID, source.ItemID)
|
||||
}
|
||||
if source.Count < requirement.Count {
|
||||
return fmt.Errorf("%w: need=%d have=%d", ErrSourceMissing, requirement.Count, source.Count)
|
||||
}
|
||||
source.Count -= requirement.Count
|
||||
removed := source.Count <= 0
|
||||
if removed {
|
||||
delete(inventory.Slots, sourceKey)
|
||||
} else {
|
||||
inventory.Slots[sourceKey] = source
|
||||
}
|
||||
box[slotKey] = command.ItemID
|
||||
inventory.UpdatedAt = command.UpdatedAt.UTC()
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveStateForSettlement(ctx, tx, characterID, account, state, command.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
result = InsertResult{
|
||||
BoxIndex: command.Definition.Index,
|
||||
BoxSlot: slotIndex,
|
||||
ItemID: command.ItemID,
|
||||
RequiredCount: requirement.Count,
|
||||
SourceSlot: command.SourceSlot,
|
||||
SourceRemainingCount: source.Count,
|
||||
SourceRemoved: removed,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return InsertResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (o *Owner) Remove(ctx context.Context, command RemoveCommand) (RemoveResult, error) {
|
||||
if o == nil || o.settlements == nil {
|
||||
return RemoveResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if characterID == "" {
|
||||
return RemoveResult{}, ErrCharacterRequired
|
||||
}
|
||||
slotIndex, requirement, err := resolveSlot(command.Definition, command.ItemID)
|
||||
if err != nil {
|
||||
return RemoveResult{}, err
|
||||
}
|
||||
if !validReturnPlacement(command.ReturnPlacement) {
|
||||
return RemoveResult{}, ErrReturnPlacementInvalid
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if command.UpdatedAt.IsZero() {
|
||||
command.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
var result RemoveResult
|
||||
err = o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
if tx.Inventory == nil || tx.Settings == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
inventory, found, loadErr := tx.Inventory.Load(ctx, characterID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
|
||||
return ErrInventoryMissing
|
||||
}
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
if inventory.Slots == nil {
|
||||
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
||||
}
|
||||
state, account, stateErr := loadStateForSettlement(ctx, tx, characterID)
|
||||
if stateErr != nil {
|
||||
return stateErr
|
||||
}
|
||||
box := ensureBox(&state, command.Definition.Index)
|
||||
stored, ok := box[strconv.Itoa(slotIndex)]
|
||||
if !ok || stored != command.ItemID {
|
||||
return ErrBoxItemMissing
|
||||
}
|
||||
targetSlot, target, ok := findTargetSlot(inventory.Slots, command.ItemID, requirement.Count, command.ReturnPlacement)
|
||||
if !ok {
|
||||
return ErrInventoryFull
|
||||
}
|
||||
if target.ItemID == 0 {
|
||||
target = dnfrepo.ItemStack{ItemID: int64(command.ItemID), Count: requirement.Count}
|
||||
} else {
|
||||
target.Count += requirement.Count
|
||||
}
|
||||
inventory.Slots[inventoryKey(dnfrepo.MainInventoryListType, targetSlot)] = target
|
||||
delete(box, strconv.Itoa(slotIndex))
|
||||
inventory.UpdatedAt = command.UpdatedAt.UTC()
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveStateForSettlement(ctx, tx, characterID, account, state, command.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
result = RemoveResult{
|
||||
BoxIndex: command.Definition.Index,
|
||||
BoxSlot: slotIndex,
|
||||
ItemID: command.ItemID,
|
||||
RequiredCount: requirement.Count,
|
||||
TargetSlot: targetSlot,
|
||||
TargetStack: target,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return RemoveResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (o *Owner) Extend(ctx context.Context, command ExtendCommand) (ExtendResult, error) {
|
||||
if o == nil || o.settlements == nil {
|
||||
return ExtendResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if characterID == "" {
|
||||
return ExtendResult{}, ErrCharacterRequired
|
||||
}
|
||||
if !validDefinition(command.Definition) || command.Definition.ExtendItemID == 0 || command.ItemID != command.Definition.ExtendItemID {
|
||||
return ExtendResult{}, ErrDefinitionInvalid
|
||||
}
|
||||
required := command.Definition.ExtendItemCount
|
||||
if required <= 0 {
|
||||
required = 1
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ExtendResult{}, err
|
||||
}
|
||||
if command.UpdatedAt.IsZero() {
|
||||
command.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
var result ExtendResult
|
||||
err := o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
if tx.Inventory == nil || tx.Settings == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
inventory, found, err := tx.Inventory.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
|
||||
return ErrInventoryMissing
|
||||
}
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
state, account, err := loadStateForSettlement(ctx, tx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var sourceKey string
|
||||
var sourceSlot int16
|
||||
var source dnfrepo.ItemStack
|
||||
for key, stack := range inventory.Slots {
|
||||
list, slot, ok := parseInventoryKey(key)
|
||||
if ok && list == dnfrepo.MainInventoryListType && stack.ItemID == int64(command.ItemID) && stack.Count >= required {
|
||||
sourceKey, sourceSlot, source = key, slot, stack
|
||||
break
|
||||
}
|
||||
}
|
||||
if sourceKey == "" {
|
||||
return fmt.Errorf("%w: item=%d", ErrSourceMissing, command.ItemID)
|
||||
}
|
||||
source.Count -= required
|
||||
removed := source.Count <= 0
|
||||
if removed {
|
||||
delete(inventory.Slots, sourceKey)
|
||||
} else {
|
||||
inventory.Slots[sourceKey] = source
|
||||
}
|
||||
expiration := state.Expiration[strconv.Itoa(int(command.Definition.Index))]
|
||||
if expiration.IsZero() {
|
||||
expiration, _ = parseExpiration(command.Definition.BaseExpirationDate)
|
||||
}
|
||||
if expiration.IsZero() {
|
||||
expiration = command.UpdatedAt.UTC()
|
||||
}
|
||||
// The ticket advances the current deadline by one day, capped at PVF max.
|
||||
expiration = expiration.Add(24 * time.Hour)
|
||||
if max, ok := parseExpiration(command.Definition.MaxExpirationDate); ok && expiration.After(max) {
|
||||
expiration = max
|
||||
}
|
||||
if state.Expiration == nil {
|
||||
state.Expiration = make(map[string]time.Time)
|
||||
}
|
||||
state.Expiration[strconv.Itoa(int(command.Definition.Index))] = expiration
|
||||
inventory.UpdatedAt = command.UpdatedAt.UTC()
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveStateForSettlement(ctx, tx, characterID, account, state, command.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
result = ExtendResult{BoxIndex: command.Definition.Index, ItemID: command.ItemID, RequiredCount: required, SourceSlot: sourceSlot, SourceRemaining: source.Count, SourceRemoved: removed, Expiration: expiration}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ExtendResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeState(state State) State {
|
||||
if state.Boxes == nil {
|
||||
state.Boxes = make(map[string]map[string]uint32)
|
||||
}
|
||||
if state.Expiration == nil {
|
||||
state.Expiration = make(map[string]time.Time)
|
||||
}
|
||||
for box, slots := range state.Boxes {
|
||||
if slots == nil {
|
||||
state.Boxes[box] = make(map[string]uint32)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// SelectCommand records the box the player is looking at. The native client
|
||||
// applies only the selected box's attributes, so this is durable player intent
|
||||
// rather than a cache: every login/actor-generation publication restores it.
|
||||
type SelectCommand struct {
|
||||
CharacterID string
|
||||
BoxIndex byte
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (o *Owner) Select(ctx context.Context, command SelectCommand) error {
|
||||
if o == nil || o.settlements == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if characterID == "" {
|
||||
return ErrCharacterRequired
|
||||
}
|
||||
if command.BoxIndex == 0 {
|
||||
return fmt.Errorf("%w: box index required", ErrDefinitionInvalid)
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if command.UpdatedAt.IsZero() {
|
||||
command.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
return o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
state, account, stateErr := loadStateForSettlement(ctx, tx, characterID)
|
||||
if stateErr != nil {
|
||||
return stateErr
|
||||
}
|
||||
if state.SelectedBox(characterID) == command.BoxIndex {
|
||||
return nil
|
||||
}
|
||||
state.SetSelectedBox(characterID, command.BoxIndex)
|
||||
return saveStateForSettlement(ctx, tx, characterID, account, state, command.UpdatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
func loadStateForSettlement(ctx context.Context, tx dnfrepo.Group, characterID string) (State, dnfrepo.AccountRecord, error) {
|
||||
if tx.Character == nil {
|
||||
return State{}, dnfrepo.AccountRecord{}, ErrOwnerUnavailable
|
||||
}
|
||||
if tx.Account == nil {
|
||||
if tx.Settings == nil {
|
||||
return State{}, dnfrepo.AccountRecord{}, ErrOwnerUnavailable
|
||||
}
|
||||
state, err := LoadState(ctx, tx.Settings, characterID)
|
||||
return state, dnfrepo.AccountRecord{}, err
|
||||
}
|
||||
character, found, err := tx.Character.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return State{}, dnfrepo.AccountRecord{}, err
|
||||
}
|
||||
if !found || strings.TrimSpace(character.AccountID) == "" {
|
||||
return State{}, dnfrepo.AccountRecord{}, ErrOwnerUnavailable
|
||||
}
|
||||
account, found, err := tx.Account.Load(ctx, character.AccountID)
|
||||
if err != nil {
|
||||
return State{}, dnfrepo.AccountRecord{}, err
|
||||
}
|
||||
if !found {
|
||||
if tx.Settings == nil {
|
||||
return State{}, dnfrepo.AccountRecord{}, ErrOwnerUnavailable
|
||||
}
|
||||
state, settingsErr := LoadState(ctx, tx.Settings, characterID)
|
||||
return state, dnfrepo.AccountRecord{}, settingsErr
|
||||
}
|
||||
raw := strings.TrimSpace(account.Metadata[MetadataKey])
|
||||
if raw != "" {
|
||||
state, err := DecodeState(raw)
|
||||
return state, account, err
|
||||
}
|
||||
if tx.Settings == nil {
|
||||
return normalizeState(State{}), account, nil
|
||||
}
|
||||
state, err := LoadState(ctx, tx.Settings, characterID)
|
||||
return state, account, err
|
||||
}
|
||||
|
||||
func saveStateForSettlement(ctx context.Context, tx dnfrepo.Group, characterID string, account dnfrepo.AccountRecord, state State, updatedAt time.Time) error {
|
||||
if tx.Account == nil {
|
||||
if tx.Settings == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
return SaveState(ctx, tx.Settings, characterID, state, updatedAt)
|
||||
}
|
||||
if strings.TrimSpace(account.AccountID) == "" {
|
||||
if tx.Settings == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
return SaveState(ctx, tx.Settings, characterID, state, updatedAt)
|
||||
}
|
||||
raw, err := EncodeState(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
account = dnfrepo.CloneAccount(account)
|
||||
if account.Metadata == nil {
|
||||
account.Metadata = make(map[string]string)
|
||||
}
|
||||
account.Metadata[MetadataKey] = raw
|
||||
account.UpdatedAt = updatedAt.UTC()
|
||||
return tx.Account.Save(ctx, account)
|
||||
}
|
||||
|
||||
func parseExpiration(raw string) (time.Time, bool) {
|
||||
raw = strings.Trim(strings.TrimSpace(raw), "`")
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02", "2006.01.02 15:04:05", "2006.01.02"} {
|
||||
if parsed, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func ensureBox(state *State, index byte) map[string]uint32 {
|
||||
if state.Boxes == nil {
|
||||
state.Boxes = make(map[string]map[string]uint32)
|
||||
}
|
||||
key := strconv.Itoa(int(index))
|
||||
box := state.Boxes[key]
|
||||
if box == nil {
|
||||
box = make(map[string]uint32)
|
||||
state.Boxes[key] = box
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
func validDefinition(definition Definition) bool {
|
||||
if len(definition.Slots) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, slot := range definition.Slots {
|
||||
if slot.ItemID == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resolveSlot(definition Definition, itemID uint32) (int, Slot, error) {
|
||||
if !validDefinition(definition) || itemID == 0 {
|
||||
return 0, Slot{}, ErrDefinitionInvalid
|
||||
}
|
||||
for index, slot := range definition.Slots {
|
||||
if slot.ItemID == itemID {
|
||||
if slot.Count <= 0 {
|
||||
slot.Count = 1
|
||||
}
|
||||
return index, slot, nil
|
||||
}
|
||||
}
|
||||
return 0, Slot{}, fmt.Errorf("%w: item=%d", ErrSourceMismatch, itemID)
|
||||
}
|
||||
|
||||
func SnapshotBonus(definition Definition, state State, now time.Time) Bonus {
|
||||
if !validDefinition(definition) {
|
||||
return Bonus{}
|
||||
}
|
||||
expiration := state.Expiration[strconv.Itoa(int(definition.Index))]
|
||||
if expiration.IsZero() {
|
||||
expiration, _ = parseExpiration(definition.BaseExpirationDate)
|
||||
}
|
||||
if expiration.IsZero() {
|
||||
expiration, _ = parseExpiration(definition.MaxExpirationDate)
|
||||
}
|
||||
if !expiration.IsZero() && !now.Before(expiration) {
|
||||
return Bonus{}
|
||||
}
|
||||
stored := state.Boxes[strconv.Itoa(int(definition.Index))]
|
||||
var result Bonus
|
||||
for index, slot := range definition.Slots {
|
||||
if stored[strconv.Itoa(index)] == slot.ItemID {
|
||||
result.Add(slot.Bonus)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validReturnPlacement(placement ReturnPlacement) bool {
|
||||
return placement.SlotStart >= 9 &&
|
||||
placement.SlotEnd >= placement.SlotStart &&
|
||||
placement.SlotEnd < dnfrepo.CrystalWarehouseFirstSlot
|
||||
}
|
||||
|
||||
func findTargetSlot(slots map[string]dnfrepo.ItemStack, itemID uint32, count int64, placement ReturnPlacement) (int16, dnfrepo.ItemStack, bool) {
|
||||
if !validReturnPlacement(placement) || count <= 0 {
|
||||
return 0, dnfrepo.ItemStack{}, false
|
||||
}
|
||||
stackLimit := placement.StackLimit
|
||||
if stackLimit <= 0 {
|
||||
stackLimit = math.MaxInt32
|
||||
}
|
||||
// PVF [stack limit] 是这个容器里该道具的总量上限:额度按容器里同 id 总量算,
|
||||
// 容器里已经有该道具的行时不能再开第二行(没写 [stack limit] 时不受约束)。
|
||||
space := dnfrepo.ContainerItemSpace(slots, dnfrepo.MainInventoryListType, int64(itemID), placement.StackLimit)
|
||||
for slot := placement.SlotStart; slot <= placement.SlotEnd; slot++ {
|
||||
key := inventoryKey(dnfrepo.MainInventoryListType, slot)
|
||||
stack, occupied := slots[key]
|
||||
if !occupied || stack.ItemID != int64(itemID) || stack.Count < 0 || stack.Count > stackLimit-count ||
|
||||
count > space {
|
||||
continue
|
||||
}
|
||||
return slot, stack, true
|
||||
}
|
||||
for slot := placement.SlotStart; slot <= placement.SlotEnd; slot++ {
|
||||
key := inventoryKey(dnfrepo.MainInventoryListType, slot)
|
||||
if _, occupied := slots[key]; !occupied {
|
||||
if !dnfrepo.CanOpenContainerItemRow(slots, dnfrepo.MainInventoryListType, int64(itemID), placement.StackLimit) {
|
||||
return 0, dnfrepo.ItemStack{}, false
|
||||
}
|
||||
return slot, dnfrepo.ItemStack{}, true
|
||||
}
|
||||
}
|
||||
return 0, dnfrepo.ItemStack{}, false
|
||||
}
|
||||
|
||||
func inventoryKey(listType byte, slot int16) string {
|
||||
return fmt.Sprintf("%d:%d", listType, slot)
|
||||
}
|
||||
|
||||
func parseInventoryKey(key string) (byte, int16, bool) {
|
||||
parts := strings.Split(strings.TrimSpace(key), ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
list, err := strconv.ParseUint(parts[0], 10, 8)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
slot, err := strconv.ParseInt(parts[1], 10, 16)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return byte(list), int16(slot), true
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package collectbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestOwnerInsertRemovePersistsInventoryAndState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := memory.NewMemoryGroup()
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "7", AccountID: "account-7"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "7",
|
||||
Slots: map[string]dnfrepo.ItemStack{"0:12": {ItemID: 7001, Count: 2}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition := Definition{Index: 2, Slots: []Slot{{ItemID: 7001, Count: 2}}}
|
||||
inserted, err := owner.Insert(ctx, InsertCommand{CharacterID: "7", Definition: definition, SourceSlot: 12, ItemID: 7001})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inserted.SourceRemainingCount != 0 || !inserted.SourceRemoved {
|
||||
t.Fatalf("insert result = %+v", inserted)
|
||||
}
|
||||
state, err := LoadState(ctx, repos.Settings, "7")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := Snapshot(definition, state); len(got) != 1 || got[0] != 7001 {
|
||||
t.Fatalf("snapshot after insert = %v", got)
|
||||
}
|
||||
removed, err := owner.Remove(ctx, RemoveCommand{CharacterID: "7", Definition: definition, ItemID: 7001, ReturnPlacement: ReturnPlacement{SlotStart: 121, SlotEnd: 176, StackLimit: 999}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed.TargetSlot != 121 || removed.TargetStack.Count != 2 {
|
||||
t.Fatalf("remove result = %+v", removed)
|
||||
}
|
||||
state, err = LoadState(ctx, repos.Settings, "7")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := Snapshot(definition, state); len(got) != 0 {
|
||||
t.Fatalf("snapshot after remove = %v", got)
|
||||
}
|
||||
inventory, found, err := repos.Inventory.Load(ctx, "7")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("inventory load = found=%t err=%v", found, err)
|
||||
}
|
||||
if _, occupied := inventory.Slots["0:12"]; occupied {
|
||||
t.Fatalf("fully consumed source row should be gone: %+v", inventory.Slots)
|
||||
}
|
||||
if inventory.Slots["0:121"].ItemID != 7001 || inventory.Slots["0:121"].Count != 2 {
|
||||
t.Fatalf("inventory after remove = %+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
// 归还同样受"一个容器里同一道具 id 只能有一行"约束:背包里已经有这件道具的行、
|
||||
// 而这一行不在 PVF 归还页范围内时,不能再开第二行,整次归还被拒绝(道具留在
|
||||
// 收藏箱里,不静默丢失)。
|
||||
func TestOwnerRemoveRefusesSecondRowWhenBagAlreadyHoldsItem(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := memory.NewMemoryGroup()
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "11", AccountID: "account-11"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "11", Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:12": {ItemID: 7011, Count: 1},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition := Definition{Index: 5, Slots: []Slot{{ItemID: 7011, Count: 2}}}
|
||||
if err := SaveState(ctx, repos.Settings, "11", State{Boxes: map[string]map[string]uint32{"5": {"0": 7011}}}, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := owner.Remove(ctx, RemoveCommand{
|
||||
CharacterID: "11",
|
||||
Definition: definition,
|
||||
ItemID: 7011,
|
||||
ReturnPlacement: ReturnPlacement{SlotStart: 121, SlotEnd: 176, StackLimit: 999},
|
||||
}); !errors.Is(err, ErrInventoryFull) {
|
||||
t.Fatalf("remove error = %v, want ErrInventoryFull", err)
|
||||
}
|
||||
inventory, _, err := repos.Inventory.Load(ctx, "11")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inventory.Slots) != 1 || inventory.Slots["0:12"].Count != 1 {
|
||||
t.Fatalf("refused return mutated the bag: %+v", inventory.Slots)
|
||||
}
|
||||
if _, occupied := inventory.Slots["0:121"]; occupied {
|
||||
t.Fatalf("refused return opened a second row: %+v", inventory.Slots)
|
||||
}
|
||||
state, err := LoadState(ctx, repos.Settings, "11")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := Snapshot(definition, state); len(got) != 1 || got[0] != 7011 {
|
||||
t.Fatalf("refused return must keep the box item, snapshot = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerRemoveReturnsOnlyToPVFMaterialPage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := memory.NewMemoryGroup()
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "10", AccountID: "account-10"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "10", Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:15": {ItemID: 7010, Count: 8}, // stale pre-fix equipment-page stack: must not be reused.
|
||||
"0:121": {ItemID: 7010, Count: 990},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition := Definition{Index: 4, Slots: []Slot{{ItemID: 7010, Count: 1}}}
|
||||
if err := SaveState(ctx, repos.Settings, "10", State{Boxes: map[string]map[string]uint32{"4": {"0": 7010}}}, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := owner.Remove(ctx, RemoveCommand{
|
||||
CharacterID: "10",
|
||||
Definition: definition,
|
||||
ItemID: 7010,
|
||||
ReturnPlacement: ReturnPlacement{SlotStart: 121, SlotEnd: 176, StackLimit: 999},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.TargetSlot != 121 || result.TargetStack.Count != 991 {
|
||||
t.Fatalf("remove target = %+v, want material page stack slot 121", result)
|
||||
}
|
||||
inventory, found, err := repos.Inventory.Load(ctx, "10")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("inventory load = found=%t err=%v", found, err)
|
||||
}
|
||||
if inventory.Slots["0:15"].Count != 8 || inventory.Slots["0:121"].Count != 991 {
|
||||
t.Fatalf("inventory after material return = %+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerExtendConsumesTicketAndPersistsExpiry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := memory.NewMemoryGroup()
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "8", AccountID: "account-8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "8", Slots: map[string]dnfrepo.ItemStack{"0:12": {ItemID: 10007344, Count: 2}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := time.Date(2034, 12, 30, 6, 0, 0, 0, time.Local)
|
||||
definition := Definition{Index: 1, BaseExpirationDate: "2034-12-30 06:00:00", MaxExpirationDate: "2035-02-01 06:00:00", ExtendItemID: 10007344, ExtendItemCount: 1, Slots: []Slot{{ItemID: 1, Count: 1}}}
|
||||
if err := SaveState(ctx, repos.Settings, "8", State{Expiration: map[string]time.Time{"1": base}}, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := owner.Extend(ctx, ExtendCommand{CharacterID: "8", Definition: definition, ItemID: 10007344, UpdatedAt: time.Now()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Expiration.Equal(base.Add(24*time.Hour)) || result.SourceRemaining != 1 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
state, err := LoadState(ctx, repos.Settings, "8")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !state.Expiration["1"].Equal(base.Add(24 * time.Hour)) {
|
||||
t.Fatalf("state = %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerInsertDefaultsZeroRequirementToOne(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := memory.NewMemoryGroup()
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "9", AccountID: "account-9"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "9", Slots: map[string]dnfrepo.ItemStack{"0:12": {ItemID: 7002, Count: 1}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := owner.Insert(ctx, InsertCommand{
|
||||
CharacterID: "9",
|
||||
Definition: Definition{Index: 3, Slots: []Slot{{ItemID: 7002}}},
|
||||
SourceSlot: 12,
|
||||
ItemID: 7002,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.RequiredCount != 1 || !result.SourceRemoved || result.SourceRemainingCount != 0 {
|
||||
t.Fatalf("insert result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerSelectPersistsPerCharacterBox(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := memory.NewMemoryGroup()
|
||||
for _, character := range []dnfrepo.CharacterRecord{
|
||||
{CharacterID: "11", AccountID: "account-11"},
|
||||
{CharacterID: "12", AccountID: "account-11"},
|
||||
} {
|
||||
if err := repos.Character.Save(ctx, character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
state := State{Boxes: map[string]map[string]uint32{"2": {"0": 10007548}}}
|
||||
raw, err := EncodeState(state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||||
AccountID: "account-11",
|
||||
Metadata: map[string]string{MetadataKey: raw},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := owner.Select(ctx, SelectCommand{CharacterID: "11", BoxIndex: 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Idempotent: the same box must not rewrite the record.
|
||||
if err := owner.Select(ctx, SelectCommand{CharacterID: "11", BoxIndex: 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := owner.Select(ctx, SelectCommand{CharacterID: "12", BoxIndex: 5}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, found, err := LoadAccountState(ctx, repos.Account, "account-11")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load state found=%t err=%v", found, err)
|
||||
}
|
||||
if got := stored.SelectedBox("11"); got != 2 {
|
||||
t.Fatalf("character 11 selection = %d, want 2", got)
|
||||
}
|
||||
if got := stored.SelectedBox("12"); got != 5 {
|
||||
t.Fatalf("character 12 selection = %d, want 5", got)
|
||||
}
|
||||
// The recorded box survives the boxes themselves being re-read, and the
|
||||
// item state is untouched.
|
||||
if got := Snapshot(Definition{Index: 2, Slots: []Slot{{ItemID: 10007548}}}, stored); len(got) != 1 || got[0] != 10007548 {
|
||||
t.Fatalf("boxes after select = %v", got)
|
||||
}
|
||||
if err := owner.Select(ctx, SelectCommand{CharacterID: "11", BoxIndex: 0}); !errors.Is(err, ErrDefinitionInvalid) {
|
||||
t.Fatalf("zero box index error = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user