Files
bd2/go/internal/server/player/equipment.go
T

2066 lines
72 KiB
Go

package player
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math"
"reflect"
"sort"
"strconv"
"strings"
"sync"
"unicode/utf8"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
)
// Equipment is one server-owned equipment instance. The immutable definition
// (name, icon, slot and base stats) remains in the client's local GameData.
type Equipment struct {
InvenIndex uint64 `json:"inven_index"`
ID uint64 `json:"id"`
Level uint64 `json:"level"`
UseChar uint64 `json:"use_char,omitempty"`
KeepFlag uint64 `json:"keep_flag,omitempty"`
LockFlag uint64 `json:"lock_flag,omitempty"`
SortID uint64 `json:"sort_id,omitempty"`
Mark string `json:"mark,omitempty"`
MainOption []EquipmentOption `json:"main_option,omitempty"`
SubOption []EquipmentOption `json:"sub_option,omitempty"`
PrivateOption *EquipmentOption `json:"private_option,omitempty"`
Rank []uint64 `json:"rank,omitempty"`
UpgradeAttempts uint64 `json:"upgrade_attempts"`
}
// EquipmentOption is the exact EquipmentOptionTable composite key used by an
// equipment instance. The client resolves the displayed stat curve locally.
type EquipmentOption struct {
GroupID uint64 `json:"group_id"`
ID uint64 `json:"id"`
}
type equipmentSnapshot struct {
Version string `json:"version"`
NextIndex uint64 `json:"next_index"`
Equipment []Equipment `json:"-"`
Granted map[string]uint64 `json:"-"`
}
// EquipmentInventory persists non-stackable equipment independently from
// ItemDBInfo inventory because the wire protocols are different types.
type EquipmentInventory struct {
mu sync.Mutex
store stateio.AtomicEntryStore
owned equipmentSnapshot
persisted equipmentSnapshot
corePresent bool
characters *CharacterStore
slots map[uint64]uint64
upgrade *gamedata.EquipmentUpgradeDesign
craft *gamedata.EquipmentCraftDesign
smelting *gamedata.EquipmentSmeltingDesign
optionReroll *gamedata.EquipmentOptionRerollDesign
statDesign *gamedata.EquipmentStatDesign
wallet *Wallet
inventory *Inventory
sessionID string
smeltCache map[string]smeltingReply
pendingReroll *equipmentOptionRerollPending
presets map[equipmentPresetKey]equipmentPreset
}
type smeltingReply struct {
code int
body []byte
}
type equipmentOptionRerollPending struct {
Equipment Equipment `json:"equipment"`
}
func (s *EquipmentInventory) AttachUpgrade(design *gamedata.EquipmentUpgradeDesign, wallet *Wallet, inventory *Inventory) error {
if design == nil || wallet == nil || inventory == nil {
return errors.New("player: incomplete equipment upgrade configuration")
}
s.mu.Lock()
defer s.mu.Unlock()
s.upgrade, s.wallet, s.inventory = design, wallet, inventory
return nil
}
func (s *EquipmentInventory) AttachCraft(design *gamedata.EquipmentCraftDesign) error {
if design == nil {
return errors.New("player: incomplete equipment crafting configuration")
}
s.mu.Lock()
defer s.mu.Unlock()
s.craft = design
return nil
}
func (s *EquipmentInventory) AttachSmelting(design *gamedata.EquipmentSmeltingDesign, wallet *Wallet, inventory *Inventory) error {
if design == nil || wallet == nil || inventory == nil {
return errors.New("player: incomplete equipment smelting configuration")
}
s.mu.Lock()
defer s.mu.Unlock()
s.smelting, s.wallet, s.inventory = design, wallet, inventory
return nil
}
func (s *EquipmentInventory) AttachOptionReroll(design *gamedata.EquipmentOptionRerollDesign, wallet *Wallet, inventory *Inventory) error {
if design == nil || wallet == nil || inventory == nil {
return errors.New("player: incomplete equipment option reroll configuration")
}
s.mu.Lock()
defer s.mu.Unlock()
s.optionReroll, s.wallet, s.inventory = design, wallet, inventory
if s.pendingReroll != nil {
if err := s.validatePendingRerollLocked(s.pendingReroll); err != nil {
return err
}
}
return nil
}
// BeginSession scopes the in-memory request replay cache. A repeated protobuf
// sequence in one login must return the first refinement result without a
// second roll or charge.
func (s *EquipmentInventory) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if id == "" || id == s.sessionID {
return
}
s.sessionID = id
s.smeltCache = make(map[string]smeltingReply)
}
func (s *EquipmentInventory) AttachSlots(slots map[uint64]uint64) error {
if len(slots) == 0 {
return errors.New("player: empty equipment slot design")
}
s.mu.Lock()
defer s.mu.Unlock()
s.slots = make(map[uint64]uint64, len(slots))
for id, slot := range slots {
if id == 0 || slot > 4 {
return errors.New("player: invalid equipment slot design")
}
s.slots[id] = slot
}
return nil
}
func (s *EquipmentInventory) AttachCharacters(characters *CharacterStore) error {
if characters == nil {
return errors.New("player: nil character store")
}
known := make(map[uint64]bool)
for _, character := range characters.RawAll() {
known[character.InvenIndex] = true
}
s.mu.Lock()
defer s.mu.Unlock()
for key := range s.presets {
if !known[key.CharacterIndex] {
return fmt.Errorf("player: equipment preset references unknown character %d", key.CharacterIndex)
}
}
s.characters = characters
return nil
}
func OpenEquipmentInventory(store stateio.Store) (*EquipmentInventory, error) {
entries, ok := store.(stateio.AtomicEntryStore)
if !ok {
return nil, errors.New("player: nil equipment store")
}
s := &EquipmentInventory{store: entries, smeltCache: make(map[string]smeltingReply), presets: make(map[equipmentPresetKey]equipmentPreset), owned: equipmentSnapshot{
Version: versionconfig.State(), NextIndex: 910000001, Granted: map[string]uint64{},
}}
data, err := store.Load("equipment")
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 equipment layout: %w", err)
}
var shape map[string]json.RawMessage
if err := json.Unmarshal(data, &shape); err != nil {
return nil, fmt.Errorf("player: decode equipment shape: %w", err)
}
for _, name := range []string{"equipment", "granted"} {
if _, exists := shape[name]; exists {
return nil, fmt.Errorf("player: equipment %s must use entries", name)
}
}
if err := json.Unmarshal(data, &s.owned); err != nil {
return nil, fmt.Errorf("player: decode equipment: %w", err)
}
} else if err := stateio.RequireNoEntries(entries, "equipment", "equipment", "granted", "reroll_pending", "presets"); err != nil {
return nil, fmt.Errorf("player: invalid equipment storage: %w", err)
}
if s.owned.Version != versionconfig.State() || s.owned.NextIndex < 910000001 {
return nil, errors.New("player: invalid saved equipment")
}
s.owned.Granted, err = loadUintEntries(entries, "equipment", "granted")
if err != nil {
return nil, err
}
rawEquipment, err := entries.ListEntries("equipment", "equipment")
if err != nil {
return nil, err
}
for key, value := range rawEquipment {
var entry Equipment
index, parseErr := strconv.ParseUint(key, 10, 64)
var shape map[string]json.RawMessage
if parseErr != nil || json.Unmarshal(value, &shape) != nil || json.Unmarshal(value, &entry) != nil || entry.InvenIndex != index {
return nil, fmt.Errorf("player: invalid equipment entry %q", key)
}
if _, present := shape["upgrade_attempts"]; !present {
return nil, errors.New("player: equipment save requires upgrade_attempts; migrate the development save")
}
s.owned.Equipment = append(s.owned.Equipment, entry)
}
sort.Slice(s.owned.Equipment, func(i, j int) bool { return s.owned.Equipment[i].InvenIndex < s.owned.Equipment[j].InvenIndex })
for _, entry := range s.owned.Equipment {
if len(entry.Rank) != 3 {
return nil, fmt.Errorf("player: equipment %d requires exactly three rank slots, found %d; repair the development save before starting", entry.InvenIndex, len(entry.Rank))
}
}
pendingEntries, err := entries.ListEntries("equipment", "reroll_pending")
if err != nil {
return nil, err
}
if len(pendingEntries) > 1 {
return nil, errors.New("player: multiple equipment option reroll candidates")
}
if raw, ok := pendingEntries["current"]; ok {
if err := stateio.RequireExactJSONObject(raw, "equipment"); err != nil {
return nil, fmt.Errorf("player: incompatible equipment option reroll candidate: %w", err)
}
var pending equipmentOptionRerollPending
if err := json.Unmarshal(raw, &pending); err != nil {
return nil, fmt.Errorf("player: decode equipment option reroll candidate: %w", err)
}
s.pendingReroll = &pending
if err := s.validatePendingRerollLocked(s.pendingReroll); err != nil {
return nil, err
}
} else if len(pendingEntries) != 0 {
return nil, errors.New("player: invalid equipment option reroll candidate key")
}
if err := s.loadEquipmentPresets(entries); err != nil {
return nil, err
}
s.persisted = cloneEquipmentSnapshot(s.owned)
return s, nil
}
func (s *EquipmentInventory) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.store.Load("equipment")
if err != nil {
return err
}
if data != nil {
return nil
}
return s.commitLocked(cloneEquipmentSnapshot(s.owned), "initial account generation")
}
// GrantOnce returns the same instance on a retry, allowing QuestClear response
// retries without duplicating ownership.
func (s *EquipmentInventory) GrantOnce(identity string, equipmentID uint64) (Equipment, error) {
if identity == "" || equipmentID == 0 {
return Equipment{}, errors.New("player: invalid equipment grant")
}
s.mu.Lock()
defer s.mu.Unlock()
if index := s.owned.Granted[identity]; index != 0 {
for _, current := range s.owned.Equipment {
if current.InvenIndex == index {
return current, nil
}
}
return Equipment{}, errors.New("player: equipment grant index is missing")
}
return s.grantLocked(identity, Equipment{ID: equipmentID})
}
// GrantGeneratedOnce saves an independently generated gacha instance. Retry
// calls return the original rolls rather than creating a second copy.
func (s *EquipmentInventory) GrantGeneratedOnce(identity string, entry Equipment) (Equipment, error) {
if identity == "" || entry.ID == 0 || len(entry.Rank) != 3 {
return Equipment{}, errors.New("player: invalid generated equipment")
}
s.mu.Lock()
defer s.mu.Unlock()
return s.grantLocked(identity, entry)
}
func (s *EquipmentInventory) grantLocked(identity string, entry Equipment) (Equipment, error) {
if index := s.owned.Granted[identity]; index != 0 {
for _, current := range s.owned.Equipment {
if current.InvenIndex == index {
return current, nil
}
}
return Equipment{}, errors.New("player: equipment grant index is missing")
}
if len(entry.Rank) == 0 {
// Rank is a fixed three-slot client field (unlock thresholds +3/+6/+9).
// Even an unenhanced item must have three explicit zero values.
entry.Rank = []uint64{0, 0, 0}
}
if len(entry.Rank) != 3 {
return Equipment{}, errors.New("player: equipment requires exactly three rank slots")
}
entry.InvenIndex = s.owned.NextIndex
next := equipmentSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex + 1,
Equipment: append([]Equipment(nil), s.owned.Equipment...), Granted: make(map[string]uint64, len(s.owned.Granted)+1)}
next.Equipment = append(next.Equipment, entry)
for k, v := range s.owned.Granted {
next.Granted[k] = v
}
next.Granted[identity] = entry.InvenIndex
if err := s.commitLocked(next, "grant"); err != nil {
return Equipment{}, err
}
return entry, nil
}
func EquipmentWire(entry Equipment) []byte {
base := wire.AppendVarint(nil, 1, entry.ID)
if entry.Level != 0 {
base = wire.AppendVarint(base, 2, entry.Level)
}
for _, option := range entry.MainOption {
base = wire.AppendBytes(base, 3, equipmentOptionWire(option))
}
for _, option := range entry.SubOption {
base = wire.AppendBytes(base, 4, equipmentOptionWire(option))
}
// The native ResolvedPrivateOption getter dereferences PrivateOption before
// checking IsValid. A present empty message selects its GameData fallback;
// an omitted field leaves the protobuf object null and crashes the UI.
var private []byte
if entry.PrivateOption != nil {
private = equipmentOptionWire(*entry.PrivateOption)
}
base = wire.AppendBytes(base, 5, private)
for _, rank := range entry.Rank {
base = wire.AppendVarint(base, 6, rank)
}
var out []byte
out = wire.AppendVarint(out, 1, entry.InvenIndex)
if entry.UseChar != 0 {
out = wire.AppendVarint(out, 2, entry.UseChar)
}
if entry.KeepFlag != 0 {
out = wire.AppendVarint(out, 3, entry.KeepFlag)
}
if entry.LockFlag != 0 {
out = wire.AppendVarint(out, 4, entry.LockFlag)
}
out = wire.AppendBytes(out, 5, base)
if entry.SortID != 0 {
out = wire.AppendVarint(out, 7, entry.SortID)
}
if entry.Mark != "" {
out = wire.AppendBytes(out, 8, []byte(entry.Mark))
}
return out
}
func equipmentOptionWire(option EquipmentOption) []byte {
out := wire.AppendVarint(nil, 1, option.GroupID)
return wire.AppendVarint(out, 2, option.ID)
}
func (s *EquipmentInventory) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/EquipInfo" && path != "/EquipUse" && path != "/EquipClear" && path != "/EquipChange" && path != "/EquipBatchUse" && path != "/EquipPresetInfo" && path != "/EquipPresetSave" && path != "/EquipPresetNameChange" && path != "/EquipUpgrade" && path != "/EquipSequenceUpgrade" && path != "/EquipSmelting" && path != "/EquipSequenceSmelting" && path != "/EquipOptionReRoll" && path != "/EquipOptionReRollConfirm" && path != "/EquipMainOptChange" && path != "/EquipMarkSet" && path != "/EquipMarkDelete" && path != "/EquipLock" && path != "/EquipMaking" && path != "/EquipBreak" && path != "/EquipMakingToBreakAuto" && path != "/EquipUpgradeToBreakAuto" {
return 0, nil, false, nil
}
if seq, found, err := wire.Varint(request, 1); err != nil || !found || seq == 0 {
return 0, nil, true, fmt.Errorf("player: %s invalid sequence", path)
}
if path == "/EquipUse" {
return s.use(request)
}
if path == "/EquipClear" {
return s.clear(request)
}
if path == "/EquipBatchUse" {
return s.batchUse(request)
}
if path == "/EquipPresetInfo" {
return s.presetInfo()
}
if path == "/EquipPresetSave" {
return s.presetSave(request)
}
if path == "/EquipPresetNameChange" {
return s.presetNameChange(request)
}
if path == "/EquipUpgrade" {
return s.upgradeOnce(request)
}
if path == "/EquipSequenceUpgrade" {
return s.upgradeSequence(request)
}
if path == "/EquipMaking" {
return s.makeEquipment(request)
}
if path == "/EquipBreak" {
return s.breakEquipment(request)
}
if path == "/EquipMakingToBreakAuto" {
return s.makeToBreakAuto(request)
}
if path == "/EquipUpgradeToBreakAuto" {
return s.upgradeToBreakAuto(request)
}
if path == "/EquipSmelting" {
return s.smeltOnce(request)
}
if path == "/EquipSequenceSmelting" {
return s.smeltSequence(request)
}
if path == "/EquipOptionReRoll" {
return s.optionRerollRequest(request)
}
if path == "/EquipOptionReRollConfirm" {
return s.optionRerollConfirm(request)
}
if path == "/EquipMainOptChange" {
return s.mainOptionChange(request)
}
if path == "/EquipChange" {
return s.change(request)
}
if path == "/EquipMarkSet" || path == "/EquipMarkDelete" {
return s.mark(path, request)
}
if path == "/EquipLock" {
return s.lock(request)
}
s.mu.Lock()
defer s.mu.Unlock()
var response []byte
for _, entry := range s.owned.Equipment {
response = wire.AppendBytes(response, 1, EquipmentWire(entry))
}
if s.pendingReroll != nil {
response = wire.AppendBytes(response, 2, EquipmentWire(s.pendingReroll.Equipment))
}
return 34, response, true, nil
}
func (s *EquipmentInventory) optionRerollRequest(request []byte) (int, []byte, bool, error) {
seq, _, _ := wire.Varint(request, 1)
index, found, err := wire.Varint(request, 2)
if err != nil || !found || index == 0 {
return 0, nil, true, errors.New("player: EquipOptionReRoll missing equipment")
}
mainLocks, err := repeatedBoolField(request, 3, "EquipOptionReRoll main lock")
if err != nil {
return 0, nil, true, err
}
subLocks, err := repeatedBoolField(request, 4, "EquipOptionReRoll sub lock")
if err != nil {
return 0, nil, true, err
}
materials, err := equipmentRequestItems(request, 5, "EquipOptionReRoll")
if err != nil {
return 0, nil, true, err
}
rerollType, _, err := wire.Varint(request, 6)
if err != nil || rerollType > 1 {
return 0, nil, true, errors.New("player: EquipOptionReRoll unsupported reroll type")
}
s.mu.Lock()
defer s.mu.Unlock()
cacheKey := s.smeltingCacheKey("option-reroll", seq)
if reply, ok := s.smeltCache[cacheKey]; ok {
return reply.code, append([]byte(nil), reply.body...), true, nil
}
if s.optionReroll == nil || s.wallet == nil || s.inventory == nil {
return 0, nil, true, errors.New("player: equipment option reroll unavailable")
}
position := s.equipmentPositionLocked(index)
if position < 0 {
return 0, nil, true, fmt.Errorf("player: EquipOptionReRoll unknown equipment %d", index)
}
current := s.owned.Equipment[position]
base := current
if s.pendingReroll != nil {
if s.pendingReroll.Equipment.InvenIndex != index {
return 0, nil, true, errors.New("player: another equipment option reroll is awaiting confirmation")
}
// A retry from the result screen carries only lock masks. The values
// being locked are therefore the last candidate, not the still-official
// equipment options. Keep chaining candidates until confirm/keep clears
// the pending result.
base = s.pendingReroll.Equipment
}
definition, ok := s.optionReroll.Lookup(current.ID)
if !ok {
return 0, nil, true, fmt.Errorf("player: equipment %d has no option reroll design", current.ID)
}
if len(mainLocks) != len(base.MainOption) || len(mainLocks) != len(definition.MainGroups) ||
len(subLocks) != len(base.SubOption) || len(subLocks) != len(definition.SubGroups) {
return 0, nil, true, errors.New("player: EquipOptionReRoll lock arrays do not match equipment options")
}
effectiveMainLocks := append([]bool(nil), mainLocks...)
if rerollType == 1 && len(effectiveMainLocks) != 0 {
// The client's has-another-option mode asks the server to leave the
// first main option alone. It is not a paid lock and remains false in
// the request mask.
effectiveMainLocks[0] = true
}
lockedCount, unlockedRerollable := uint64(0), uint64(0)
for i, locked := range mainLocks {
if base.MainOption[i].GroupID != definition.MainGroups[i] || base.MainOption[i].ID == 0 {
return 0, nil, true, fmt.Errorf("player: equipment %d main option %d does not match GameData", index, i)
}
canReroll := len(s.optionReroll.Groups[definition.MainGroups[i]].Choices) >= 2
if locked && (i == 0 || !canReroll) {
return 0, nil, true, fmt.Errorf("player: EquipOptionReRoll main option %d cannot be locked", i)
}
if locked {
lockedCount++
} else if canReroll && !(rerollType == 1 && i == 0) {
unlockedRerollable++
}
}
for i, locked := range subLocks {
if base.SubOption[i].GroupID != definition.SubGroups[i] || base.SubOption[i].ID == 0 {
return 0, nil, true, fmt.Errorf("player: equipment %d sub option %d does not match GameData", index, i)
}
canReroll := len(s.optionReroll.Groups[definition.SubGroups[i]].Choices) >= 2
if locked && !canReroll {
return 0, nil, true, fmt.Errorf("player: EquipOptionReRoll sub option %d cannot be locked", i)
}
if locked {
lockedCount++
} else if canReroll {
unlockedRerollable++
}
}
if unlockedRerollable == 0 {
return 0, nil, true, errors.New("player: EquipOptionReRoll must leave a rerollable option unlocked")
}
costs, err := s.optionReroll.Cost(current.ID, lockedCount)
if err != nil {
return 0, nil, true, err
}
gold, consumed, err := validateOptionRerollMaterials(costs, materials, s.optionReroll.Conversion)
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 equipment option reroll")
}
if len(consumed) != 0 {
if err := s.inventory.CanConsume(consumed); err != nil {
return 0, nil, true, err
}
}
privateLocks := make([]bool, len(definition.PrivateGroups))
for i := range privateLocks {
privateLocks[i] = true
}
rolled, err := s.optionReroll.RollUnlocked(current.ID, gamedata.EquipmentOptionRerollLocks{Main: effectiveMainLocks, Sub: subLocks, Private: privateLocks})
if err != nil {
return 0, nil, true, err
}
candidate := cloneEquipment(base)
for i := range candidate.MainOption {
if !effectiveMainLocks[i] {
candidate.MainOption[i] = EquipmentOption{GroupID: rolled.Main[i].GroupID, ID: rolled.Main[i].ID}
}
}
for i := range candidate.SubOption {
if !subLocks[i] {
candidate.SubOption[i] = EquipmentOption{GroupID: rolled.Sub[i].GroupID, ID: rolled.Sub[i].ID}
}
}
pending := &equipmentOptionRerollPending{Equipment: candidate}
if err := s.commitOptionRerollLocked(pending, consumed, gold, "equip-option-reroll:"+cacheKey); err != nil {
return 0, nil, true, err
}
var response []byte
for _, option := range candidate.MainOption {
response = wire.AppendBytes(response, 1, equipmentOptionWire(option))
}
for _, option := range candidate.SubOption {
response = wire.AppendBytes(response, 2, equipmentOptionWire(option))
}
s.smeltCache[cacheKey] = smeltingReply{code: 192, body: append([]byte(nil), response...)}
return 192, response, true, nil
}
func (s *EquipmentInventory) optionRerollConfirm(request []byte) (int, []byte, bool, error) {
seq, _, _ := wire.Varint(request, 1)
index, found, err := wire.Varint(request, 2)
if err != nil || !found || index == 0 {
return 0, nil, true, errors.New("player: EquipOptionReRollConfirm missing equipment")
}
confirm, err := optionalBoolField(request, 3, "EquipOptionReRollConfirm confirm")
if err != nil {
return 0, nil, true, err
}
s.mu.Lock()
cacheKey := s.smeltingCacheKey("option-reroll-confirm", seq)
if reply, ok := s.smeltCache[cacheKey]; ok {
s.mu.Unlock()
return reply.code, append([]byte(nil), reply.body...), true, nil
}
if s.pendingReroll == nil || s.pendingReroll.Equipment.InvenIndex != index {
s.mu.Unlock()
return 0, nil, true, fmt.Errorf("player: equipment %d has no option reroll awaiting confirmation", index)
}
position := s.equipmentPositionLocked(index)
if position < 0 {
s.mu.Unlock()
return 0, nil, true, fmt.Errorf("player: EquipOptionReRollConfirm unknown equipment %d", index)
}
next := cloneEquipmentSnapshot(s.owned)
if confirm {
next.Equipment[position].MainOption = append([]EquipmentOption(nil), s.pendingReroll.Equipment.MainOption...)
next.Equipment[position].SubOption = append([]EquipmentOption(nil), s.pendingReroll.Equipment.SubOption...)
}
if err := s.persistEquipmentState(next, nil, true, "option reroll confirm"); err != nil {
s.mu.Unlock()
return 0, nil, true, err
}
s.owned = next
s.persisted = cloneEquipmentSnapshot(next)
s.pendingReroll = nil
entry := cloneEquipment(next.Equipment[position])
s.mu.Unlock()
response := wire.AppendBytes(nil, 1, EquipmentWire(entry))
if character, ok := s.equippedCharacter(entry); ok {
response = wire.AppendBytes(response, 2, CharacterWire(character))
}
s.mu.Lock()
s.smeltCache[cacheKey] = smeltingReply{code: 193, body: append([]byte(nil), response...)}
s.mu.Unlock()
return 193, response, true, nil
}
func (s *EquipmentInventory) mainOptionChange(request []byte) (int, []byte, bool, error) {
index, found, err := wire.Varint(request, 2)
if err != nil || !found || index == 0 {
return 0, nil, true, errors.New("player: EquipMainOptChange missing equipment")
}
groupID, groupFound, err := wire.Varint(request, 3)
if err != nil || !groupFound || groupID == 0 {
return 0, nil, true, errors.New("player: EquipMainOptChange missing option group")
}
optionID, optionFound, err := wire.Varint(request, 4)
if err != nil || !optionFound || optionID == 0 {
return 0, nil, true, errors.New("player: EquipMainOptChange missing option")
}
s.mu.Lock()
locked := true
defer func() {
if locked {
s.mu.Unlock()
}
}()
if s.optionReroll == nil {
return 0, nil, true, errors.New("player: equipment option design unavailable")
}
position := s.equipmentPositionLocked(index)
if position < 0 {
return 0, nil, true, fmt.Errorf("player: EquipMainOptChange unknown equipment %d", index)
}
current := s.owned.Equipment[position]
definition, ok := s.optionReroll.Lookup(current.ID)
if !ok || definition.PrivateUniqueCharID == 0 || len(definition.MainGroups) == 0 || len(current.MainOption) == 0 {
return 0, nil, true, fmt.Errorf("player: equipment %d has no changeable main option", current.ID)
}
group, ok := s.optionReroll.Groups[groupID]
if !ok || groupID != definition.MainGroups[0] || current.MainOption[0].GroupID != groupID || len(group.Choices) < 2 || !optionChoiceExists(group, optionID) {
return 0, nil, true, fmt.Errorf("player: equipment %d main option %d/%d is not allowed", current.ID, groupID, optionID)
}
next := cloneEquipmentSnapshot(s.owned)
next.Equipment[position].MainOption[0] = EquipmentOption{GroupID: groupID, ID: optionID}
pending := s.pendingReroll
pendingDirty := false
if pending != nil && pending.Equipment.InvenIndex == index {
copy := &equipmentOptionRerollPending{Equipment: cloneEquipment(pending.Equipment)}
if len(copy.Equipment.MainOption) == 0 || copy.Equipment.MainOption[0].GroupID != groupID {
return 0, nil, true, errors.New("player: option reroll candidate has no matching main option")
}
copy.Equipment.MainOption[0] = EquipmentOption{GroupID: groupID, ID: optionID}
pending = copy
pendingDirty = true
}
if err := s.persistEquipmentState(next, pending, pendingDirty, "main option change"); err != nil {
return 0, nil, true, err
}
s.owned = next
s.persisted = cloneEquipmentSnapshot(next)
if pendingDirty {
s.pendingReroll = pending
}
// Character health reads account equipment through pictorial ownership.
// Release the equipment lock before resolving the equipped character.
s.mu.Unlock()
locked = false
var response []byte
if character, ok := s.equippedCharacter(current); ok {
response = wire.AppendBytes(response, 1, CharacterWire(character))
}
return 537, response, true, nil
}
func repeatedBoolField(data []byte, number int, name string) ([]bool, error) {
var result []bool
err := wire.Walk(data, func(field wire.Field) error {
if field.Number != number {
return nil
}
switch field.Type {
case 0:
value, count := binary.Uvarint(field.Value)
if count <= 0 || value > 1 {
return fmt.Errorf("player: %s is invalid", name)
}
result = append(result, value == 1)
case 2:
for offset := 0; offset < len(field.Value); {
value, count := binary.Uvarint(field.Value[offset:])
if count <= 0 || value > 1 {
return fmt.Errorf("player: %s is invalid", name)
}
result = append(result, value == 1)
offset += count
}
default:
return fmt.Errorf("player: %s is invalid", name)
}
return nil
})
return result, err
}
func optionalBoolField(data []byte, number int, name string) (bool, error) {
value := false
seen := false
err := wire.Walk(data, func(field wire.Field) error {
if field.Number != number {
return nil
}
if seen || field.Type != 0 {
return fmt.Errorf("player: %s is invalid", name)
}
raw, count := binary.Uvarint(field.Value)
if count <= 0 || raw > 1 {
return fmt.Errorf("player: %s is invalid", name)
}
seen = true
value = raw == 1
return nil
})
return value, err
}
const (
equipUpgradeSuccess = iota
equipUpgradeFail
equipUpgradeStopMaxLevel
equipUpgradeStopSuccess
equipUpgradeStopNotEnough
equipUpgradeStopGoldLimit
equipUpgradeStopTargetLevel
equipUpgradeStopMaxTryCount
)
func (s *EquipmentInventory) upgradeOnce(request []byte) (int, []byte, bool, error) {
index, found, err := wire.Varint(request, 2)
if err != nil || !found || index == 0 {
return 0, nil, true, errors.New("player: EquipUpgrade missing equipment")
}
var materials []Item
err = wire.Walk(request, func(field wire.Field) error {
if field.Number != 3 {
return nil
}
if field.Type != 2 {
return errors.New("player: EquipUpgrade invalid 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, 8: &item.ExpiryTime, 9: &item.SortID, 10: &item.UseCount}); err != nil {
return err
}
if item.Type == 0 || item.Count == 0 || (item.Type == 4 && (item.ID != 0 || item.InvenIndex != 0)) || (item.Type != 4 && (item.ID == 0 || item.InvenIndex == 0)) {
return errors.New("player: EquipUpgrade invalid material")
}
materials = append(materials, item)
return nil
})
if err != nil {
return 0, nil, true, err
}
if len(materials) == 0 {
return 0, nil, true, errors.New("player: EquipUpgrade has no material")
}
s.mu.Lock()
entry, success, _, _, err := s.attemptUpgradeLocked(index, materials)
s.mu.Unlock()
if err != nil {
return 0, nil, true, err
}
result := uint64(equipUpgradeFail)
if success {
result = equipUpgradeSuccess
}
response := wire.AppendBytes(nil, 1, EquipmentWire(entry))
if result != 0 {
response = wire.AppendVarint(response, 2, result)
}
if character, ok := s.equippedCharacter(entry); ok {
response = wire.AppendBytes(response, 3, CharacterWire(character))
}
return 37, response, true, nil
}
func (s *EquipmentInventory) upgradeSequence(request []byte) (int, []byte, bool, error) {
index, found, err := wire.Varint(request, 2)
if err != nil || !found || index == 0 {
return 0, nil, true, errors.New("player: EquipSequenceUpgrade missing equipment")
}
count, found, err := wire.Varint(request, 3)
if err != nil || !found || count == 0 || count > 100000 {
return 0, nil, true, errors.New("player: EquipSequenceUpgrade invalid attempt count")
}
goldLimit, _, err := wire.Varint(request, 5)
if err != nil {
return 0, nil, true, errors.New("player: EquipSequenceUpgrade invalid gold limit")
}
target, _, err := wire.Varint(request, 6)
if err != nil {
return 0, nil, true, errors.New("player: EquipSequenceUpgrade invalid target")
}
s.mu.Lock()
if s.upgrade == nil || s.wallet == nil {
s.mu.Unlock()
return 0, nil, true, errors.New("player: equipment upgrade unavailable")
}
position := s.equipmentPositionLocked(index)
if position < 0 {
s.mu.Unlock()
return 0, nil, true, fmt.Errorf("player: EquipSequenceUpgrade unknown equipment %d", index)
}
maximum := s.upgrade.MaxLevel[s.owned.Equipment[position].ID]
if target == 0 || target > maximum {
target = maximum
}
var attempts, usedGold uint64
result := uint64(equipUpgradeStopMaxTryCount)
var consumed []Item
var lack []Item
for attempts < count {
entry := s.owned.Equipment[position]
if entry.Level >= maximum {
result = equipUpgradeStopMaxLevel
break
}
if entry.Level >= target {
result = equipUpgradeStopTargetLevel
break
}
level, _, designErr := s.upgrade.Level(entry.ID, entry.Level)
if designErr != nil {
s.mu.Unlock()
return 0, nil, true, designErr
}
materials, gold, costErr := s.selectUpgradeCosts(level.Costs)
if costErr != nil {
result = equipUpgradeStopNotEnough
lack = upgradeLackItems(level.Costs)
break
}
if goldLimit != 0 && usedGold+gold > goldLimit {
result = equipUpgradeStopGoldLimit
break
}
if gold != 0 && !s.wallet.CanSpendGold(gold) {
result = equipUpgradeStopNotEnough
lack = upgradeLackItems(level.Costs)
break
}
updated, success, spent, actual, attemptErr := s.attemptUpgradeLocked(index, materials)
if attemptErr != nil {
s.mu.Unlock()
return 0, nil, true, attemptErr
}
attempts++
usedGold += spent
consumed = append(consumed, actual...)
position = s.equipmentPositionLocked(index)
if success && updated.Level >= target {
result = equipUpgradeStopTargetLevel
if updated.Level >= maximum {
result = equipUpgradeStopMaxLevel
}
break
}
}
entry := s.owned.Equipment[position]
s.mu.Unlock()
response := wire.AppendBytes(nil, 1, EquipmentWire(entry))
if character, ok := s.equippedCharacter(entry); ok {
response = wire.AppendBytes(response, 2, CharacterWire(character))
}
response = wire.AppendVarint(response, 3, result)
response = wire.AppendVarint(response, 4, attempts)
for _, item := range consumed {
response = wire.AppendBytes(response, 5, ItemWire(item))
}
for _, item := range lack {
response = wire.AppendBytes(response, 6, ItemWire(item))
}
if usedGold != 0 {
response = wire.AppendVarint(response, 7, usedGold)
}
return 176, response, true, nil
}
func (s *EquipmentInventory) smeltOnce(request []byte) (int, []byte, bool, error) {
seq, _, _ := wire.Varint(request, 1)
index, found, err := wire.Varint(request, 2)
if err != nil || !found || index == 0 {
return 0, nil, true, errors.New("player: EquipSmelting missing equipment")
}
materials, err := equipmentRequestItems(request, 3, "EquipSmelting")
if err != nil {
return 0, nil, true, err
}
s.mu.Lock()
locked := true
defer func() {
if locked {
s.mu.Unlock()
}
}()
cacheKey := s.smeltingCacheKey("single", seq)
if reply, ok := s.smeltCache[cacheKey]; ok {
return reply.code, append([]byte(nil), reply.body...), true, nil
}
position, current, err := s.smeltingEquipmentLocked(index)
if err != nil {
return 0, nil, true, err
}
costs, err := s.smelting.Cost(current.ID)
if err != nil {
return 0, nil, true, err
}
gold, _, err := validateSmeltingMaterials(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 equipment smelting")
}
var itemMaterials []Item
var consumedMileageMaterial uint64
for _, item := range materials {
if item.Type != 4 {
itemMaterials = append(itemMaterials, item)
}
if item.Type == s.smelting.Mileage.UseType && item.ID == s.smelting.Mileage.UseID {
consumedMileageMaterial += item.Count
}
}
if len(itemMaterials) != 0 {
if err := s.inventory.CanConsume(itemMaterials); err != nil {
return 0, nil, true, err
}
}
candidate, err := s.smelting.RollCandidate(current.ID)
if err != nil {
return 0, nil, true, err
}
currentScore, err := s.smelting.Score(current.ID, current.Rank)
if err != nil {
return 0, nil, true, err
}
candidateScore, err := s.smelting.Score(current.ID, candidate)
if err != nil {
return 0, nil, true, err
}
success := candidateScore > currentScore
next := cloneEquipmentSnapshot(s.owned)
if success {
next.Equipment[position].Rank = append([]uint64(nil), candidate...)
}
currency, earned, err := s.commitSmeltingLocked(
next, itemMaterials, gold, consumedMileageMaterial,
"equip-smelting:"+cacheKey, "smelting")
if err != nil {
return 0, nil, true, err
}
current = next.Equipment[position]
response := wire.AppendBytes(nil, 1, EquipmentWire(current))
s.mu.Unlock()
locked = false
if character, ok := s.equippedCharacter(current); ok {
response = wire.AppendBytes(response, 2, CharacterWire(character))
}
if !success {
response = wire.AppendVarint(response, 3, equipUpgradeFail)
for _, rank := range candidate {
response = wire.AppendVarint(response, 4, rank)
}
}
response = appendSmeltingMileage(response, 5, 6, currency.EquipMileageExchangeGage, s.smelting.Mileage, earned)
s.mu.Lock()
s.smeltCache[cacheKey] = smeltingReply{code: 105, body: append([]byte(nil), response...)}
s.mu.Unlock()
return 105, response, true, nil
}
func (s *EquipmentInventory) smeltSequence(request []byte) (int, []byte, bool, error) {
seq, _, _ := wire.Varint(request, 1)
index, found, err := wire.Varint(request, 2)
if err != nil || !found || index == 0 {
return 0, nil, true, errors.New("player: EquipSequenceSmelting missing equipment")
}
count, found, err := wire.Varint(request, 3)
if err != nil || !found || count == 0 {
return 0, nil, true, errors.New("player: EquipSequenceSmelting invalid attempt count")
}
target, _, err := wire.Varint(request, 4)
if err != nil {
return 0, nil, true, errors.New("player: EquipSequenceSmelting invalid target score")
}
s.mu.Lock()
locked := true
defer func() {
if locked {
s.mu.Unlock()
}
}()
cacheKey := s.smeltingCacheKey("sequence", seq)
if reply, ok := s.smeltCache[cacheKey]; ok {
return reply.code, append([]byte(nil), reply.body...), true, nil
}
position, current, err := s.smeltingEquipmentLocked(index)
if err != nil {
return 0, nil, true, err
}
if count > s.smelting.MaxStreak {
return 0, nil, true, fmt.Errorf("player: EquipSequenceSmelting attempt count %d exceeds %d", count, s.smelting.MaxStreak)
}
maximumRanks, err := s.smelting.MaximumRanks(current.ID)
if err != nil {
return 0, nil, true, err
}
maximumScore, err := s.smelting.Score(current.ID, maximumRanks)
if err != nil {
return 0, nil, true, err
}
if target > maximumScore {
return 0, nil, true, fmt.Errorf("player: EquipSequenceSmelting target %d exceeds %d", target, maximumScore)
}
costs, err := s.smelting.Cost(current.ID)
if err != nil {
return 0, nil, true, err
}
currentScore, err := s.smelting.Score(current.ID, current.Rank)
if err != nil {
return 0, nil, true, err
}
// The 2.35.10 client splits a requested sequence into packets of at most
// 1000 attempts. Exhausting this packet is not the user's global max-try
// stop: UPGRADE_SUCCESS tells the client to send the next chunk. Terminal
// stop values are reserved for target/max score and insufficient resources.
result := uint64(equipUpgradeSuccess)
var attempts, successes uint64
if currentScore >= maximumScore {
result = equipUpgradeStopMaxLevel
} else if target != 0 && currentScore >= target {
result = equipUpgradeStopTargetLevel
}
for attempts < count && result == equipUpgradeSuccess {
if _, _, selectErr := s.selectSmeltingCosts(costs, attempts+1); selectErr != nil {
result = equipUpgradeStopNotEnough
break
}
candidate, rollErr := s.smelting.RollCandidate(current.ID)
if rollErr != nil {
return 0, nil, true, rollErr
}
candidateScore, scoreErr := s.smelting.Score(current.ID, candidate)
if scoreErr != nil {
return 0, nil, true, scoreErr
}
attempts++
if candidateScore > currentScore {
current.Rank = append([]uint64(nil), candidate...)
currentScore = candidateScore
successes++
}
if currentScore >= maximumScore {
result = equipUpgradeStopMaxLevel
} else if target != 0 && currentScore >= target {
result = equipUpgradeStopTargetLevel
}
}
var consumed, lack []Item
var gold, mileageMaterial, earned uint64
currency := s.wallet.Snapshot()
if attempts != 0 {
consumed, gold, err = s.selectSmeltingCosts(costs, attempts)
if err != nil {
return 0, nil, true, err
}
for _, item := range consumed {
if item.Type == s.smelting.Mileage.UseType && item.ID == s.smelting.Mileage.UseID {
mileageMaterial += item.Count
}
}
var itemMaterials []Item
for _, item := range consumed {
if item.Type != 4 {
itemMaterials = append(itemMaterials, item)
}
}
next := cloneEquipmentSnapshot(s.owned)
next.Equipment[position].Rank = append([]uint64(nil), current.Rank...)
currency, earned, err = s.commitSmeltingLocked(
next, itemMaterials, gold, mileageMaterial,
"equip-sequence-smelting:"+cacheKey, "sequence smelting")
if err != nil {
return 0, nil, true, err
}
current = next.Equipment[position]
}
// NotEnough is used only when the next requested attempt could not be
// funded. Exhausting this packet retains Success so the client can continue
// a sequence whose total requested count exceeds the 1000-attempt chunk.
if result == equipUpgradeStopNotEnough {
lack = upgradeLackItems(costs)
}
response := wire.AppendBytes(nil, 1, EquipmentWire(current))
s.mu.Unlock()
locked = false
if character, ok := s.equippedCharacter(current); ok {
response = wire.AppendBytes(response, 2, CharacterWire(character))
}
response = wire.AppendVarint(response, 3, result)
response = wire.AppendVarint(response, 4, attempts)
for _, item := range consumed {
response = wire.AppendBytes(response, 5, ItemWire(item))
}
for _, item := range lack {
response = wire.AppendBytes(response, 6, ItemWire(item))
}
response = appendSmeltingMileage(response, 7, 8, currency.EquipMileageExchangeGage, s.smelting.Mileage, earned)
if successes != 0 {
response = wire.AppendVarint(response, 9, successes)
}
s.mu.Lock()
s.smeltCache[cacheKey] = smeltingReply{code: 177, body: append([]byte(nil), response...)}
s.mu.Unlock()
return 177, response, true, nil
}
func (s *EquipmentInventory) smeltingCacheKey(kind string, seq uint64) string {
return kind + ":" + s.sessionID + ":seq:" + strconv.FormatUint(seq, 10)
}
func (s *EquipmentInventory) smeltingEquipmentLocked(index uint64) (int, Equipment, error) {
if s.smelting == nil || s.wallet == nil || s.inventory == nil {
return -1, Equipment{}, errors.New("player: equipment smelting unavailable")
}
position := s.equipmentPositionLocked(index)
if position < 0 {
return -1, Equipment{}, fmt.Errorf("player: unknown equipment %d", index)
}
entry := s.owned.Equipment[position]
design, ok := s.smelting.Equipment[entry.ID]
if !ok || entry.Level != design.MaxLevel || len(entry.Rank) != 3 {
return -1, Equipment{}, fmt.Errorf("player: equipment %d is not ready for smelting", index)
}
if _, err := s.smelting.Score(entry.ID, entry.Rank); err != nil {
return -1, Equipment{}, fmt.Errorf("player: equipment %d has invalid smelting rank: %w", index, err)
}
return position, entry, nil
}
func equipmentRequestItems(request []byte, number int, operation string) ([]Item, error) {
var result []Item
err := wire.Walk(request, func(field wire.Field) error {
if field.Number != number {
return nil
}
if field.Type != 2 {
return fmt.Errorf("player: %s invalid material", operation)
}
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, 8: &item.ExpiryTime, 9: &item.SortID, 10: &item.UseCount}); err != nil {
return err
}
if item.Type == 0 || item.Count == 0 || (item.Type == 4 && (item.ID != 0 || item.InvenIndex != 0)) || (item.Type != 4 && (item.ID == 0 || item.InvenIndex == 0)) {
return fmt.Errorf("player: %s invalid material", operation)
}
result = append(result, item)
return nil
})
if err != nil {
return nil, err
}
if len(result) == 0 {
return nil, fmt.Errorf("player: %s has no material", operation)
}
return result, nil
}
func validateSmeltingMaterials(costs []gamedata.PromotionCost, materials []Item) (gold, mileageMaterial uint64, err error) {
want := make(map[[2]uint64]uint64, len(costs))
for _, cost := range costs {
want[[2]uint64{cost.Type, cost.ID}] += cost.Count
}
got := make(map[[2]uint64]uint64, len(materials))
for _, item := range materials {
got[[2]uint64{item.Type, item.ID}] += item.Count
if item.Type == 4 {
gold += item.Count
} else {
mileageMaterial += item.Count
}
}
if len(got) != len(want) {
return 0, 0, errors.New("player: equipment smelting material kinds mismatch")
}
for key, count := range want {
if got[key] != count {
return 0, 0, fmt.Errorf("player: equipment smelting material %d/%d=%d want=%d", key[0], key[1], got[key], count)
}
}
return gold, mileageMaterial, nil
}
func (s *EquipmentInventory) selectSmeltingCosts(costs []gamedata.PromotionCost, attempts uint64) ([]Item, uint64, error) {
var result []Item
var gold uint64
for _, cost := range costs {
if attempts != 0 && cost.Count > ^uint64(0)/attempts {
return nil, 0, errors.New("player: equipment smelting cost overflow")
}
count := cost.Count * attempts
switch cost.Type {
case 4:
if cost.ID != 0 || gold != 0 || !s.wallet.CanSpendGold(count) {
return nil, 0, errors.New("player: insufficient equipment smelting gold")
}
gold = count
result = append(result, Item{Type: 4, Count: count})
case 8:
items, err := s.inventory.SelectMutable(cost.Type, cost.ID, count)
if err != nil {
return nil, 0, err
}
result = append(result, items...)
default:
return nil, 0, fmt.Errorf("player: unsupported equipment smelting cost type %d", cost.Type)
}
}
return result, gold, nil
}
func appendSmeltingMileage(response []byte, gaugeField, rewardField int, gauge uint64, mileage gamedata.EquipmentSmeltingMileage, earned uint64) []byte {
if gauge != 0 {
response = wire.AppendVarint(response, gaugeField, gauge)
}
if earned != 0 {
reward := ItemWire(Item{ID: mileage.RewardID, Type: mileage.RewardType, Count: earned})
bundle := wire.AppendBytes(nil, 1, reward)
response = wire.AppendBytes(response, rewardField, bundle)
}
return response
}
// commitSmeltingLocked keeps refinement's three typed snapshots synchronized
// in memory. Caller holds equipment.mu; this method takes the remaining locks
// in wallet -> inventory order, calculates every candidate before writing,
// then publishes all three only after their saves succeed. The surrounding
// request transaction supplies durable all-or-none recovery across the writes.
func (s *EquipmentInventory) commitSmeltingLocked(
nextEquipment equipmentSnapshot,
consumed []Item,
gold, mileageMaterial uint64,
identity, operation string,
) (Currency, uint64, error) {
if identity == "" || mileageMaterial == 0 || s.wallet == nil || s.inventory == nil {
return Currency{}, 0, errors.New("player: invalid transactional equipment smelting")
}
s.wallet.mu.Lock()
defer s.wallet.mu.Unlock()
s.inventory.mu.Lock()
defer s.inventory.mu.Unlock()
nextWallet := cloneWallet(s.wallet.state)
if nextWallet.Spent[identity] {
return Currency{}, 0, errors.New("player: equipment smelting request was already committed")
}
if nextWallet.Gold < gold {
return Currency{}, 0, errors.New("player: insufficient gold for equipment smelting")
}
nextWallet.Gold -= gold
nextWallet.Spent[identity] = true
threshold, rewardCount := s.smelting.Mileage.UseCount, s.smelting.Mileage.RewardCount
if threshold == 0 || rewardCount == 0 || nextWallet.EquipMileageExchangeGage >= threshold ||
math.MaxUint64-nextWallet.EquipMileageExchangeGage < mileageMaterial {
return Currency{}, 0, errors.New("player: invalid equipment smelting gauge")
}
total := nextWallet.EquipMileageExchangeGage + mileageMaterial
exchanges := total / threshold
earned := exchanges * rewardCount
if exchanges != 0 && earned/exchanges != rewardCount || math.MaxUint64-nextWallet.EquipMileage < earned {
return Currency{}, 0, errors.New("player: equipment mileage overflow")
}
nextWallet.EquipMileageExchangeGage = total % threshold
nextWallet.EquipMileage += earned
nextItems := cloneOwnedSnapshot(s.inventory.owned)
for _, want := range consumed {
if err := consumeOwnedItem(&nextItems, want); err != nil {
return Currency{}, 0, err
}
}
walletData, err := json.Marshal(nextWallet)
if err != nil {
return Currency{}, 0, err
}
if err := s.store.SaveWithEntries("wallet", walletData, entry("spent", identity, []byte("true"))); err != nil {
return Currency{}, 0, fmt.Errorf("player: persist equipment %s wallet: %w", operation, err)
}
if err := s.inventory.commitOwned(nextItems); err != nil {
return Currency{}, 0, fmt.Errorf("player: persist equipment %s items: %w", operation, err)
}
if err := s.persistEquipment(nextEquipment, operation); err != nil {
return Currency{}, 0, err
}
s.owned = nextEquipment
s.persisted = cloneEquipmentSnapshot(nextEquipment)
s.wallet.state = nextWallet
s.inventory.owned = nextItems
return nextWallet.Currency, earned, nil
}
func consumeOwnedItem(next *ownedSnapshot, want Item) error {
if next == nil || want.InvenIndex == 0 || want.ID == 0 || want.Type == 0 || want.Count == 0 {
return errors.New("player: invalid item consumption")
}
for i, current := range next.Items {
if current.InvenIndex != want.InvenIndex {
continue
}
if current.ID != want.ID || current.Type != want.Type || current.Count < want.Count {
return fmt.Errorf("player: item %d consumption mismatch", want.InvenIndex)
}
current.Count -= want.Count
if current.Count == 0 {
next.Items = append(next.Items[:i], next.Items[i+1:]...)
} else {
next.Items[i] = current
}
return nil
}
return fmt.Errorf("player: item %d is not mutable-owned", want.InvenIndex)
}
func validateOptionRerollMaterials(costs []gamedata.PromotionCost, materials []Item, conversion *gamedata.EquipmentOptionRerollConversion) (uint64, []Item, error) {
if len(costs) == 0 || len(materials) == 0 {
return 0, nil, errors.New("player: EquipOptionReRoll has no material")
}
expected := make(map[[2]uint64]uint64, len(costs))
for _, cost := range costs {
key := [2]uint64{cost.Type, cost.ID}
if cost.Type == 0 || cost.Count == 0 || (cost.Type == 4 && cost.ID != 0) || math.MaxUint64-expected[key] < cost.Count {
return 0, nil, errors.New("player: invalid equipment option reroll cost")
}
expected[key] += cost.Count
}
actual := make(map[[2]uint64]uint64, len(materials))
consumed := make([]Item, 0, len(materials))
for _, material := range materials {
key := [2]uint64{material.Type, material.ID}
if math.MaxUint64-actual[key] < material.Count {
return 0, nil, errors.New("player: EquipOptionReRoll material count overflows")
}
actual[key] += material.Count
if material.Type != 4 {
consumed = append(consumed, material)
}
}
for key, want := range expected {
if conversion != nil && key == ([2]uint64{conversion.TargetType, conversion.TargetID}) &&
expected[[2]uint64{conversion.SourceType, conversion.SourceID}] == 0 {
targetKey := key
sourceKey := [2]uint64{conversion.SourceType, conversion.SourceID}
targetCount := actual[targetKey]
if targetCount > want || conversion.Ratio == 0 || want-targetCount > math.MaxUint64/conversion.Ratio || actual[sourceKey] != (want-targetCount)*conversion.Ratio {
return 0, nil, fmt.Errorf("player: EquipOptionReRoll converted material %d/%d does not match cost", key[0], key[1])
}
delete(actual, targetKey)
delete(actual, sourceKey)
continue
}
if actual[key] != want {
return 0, nil, fmt.Errorf("player: EquipOptionReRoll material %d/%d=%d want=%d", key[0], key[1], actual[key], want)
}
delete(actual, key)
}
if len(actual) != 0 {
return 0, nil, errors.New("player: EquipOptionReRoll material kinds mismatch")
}
return expected[[2]uint64{4, 0}], consumed, nil
}
func (s *EquipmentInventory) commitOptionRerollLocked(pending *equipmentOptionRerollPending, consumed []Item, gold uint64, identity string) error {
if pending == nil || identity == "" || s.wallet == nil || s.inventory == nil {
return errors.New("player: invalid transactional equipment option reroll")
}
s.wallet.mu.Lock()
defer s.wallet.mu.Unlock()
s.inventory.mu.Lock()
defer s.inventory.mu.Unlock()
nextWallet := cloneWallet(s.wallet.state)
if nextWallet.Spent[identity] {
return errors.New("player: equipment option reroll request was already committed")
}
if nextWallet.Gold < gold {
return errors.New("player: insufficient gold for equipment option reroll")
}
nextWallet.Gold -= gold
nextWallet.Spent[identity] = true
nextItems := cloneOwnedSnapshot(s.inventory.owned)
for _, want := range consumed {
if err := consumeOwnedItem(&nextItems, want); err != nil {
return err
}
}
walletData, err := json.Marshal(nextWallet)
if err != nil {
return err
}
if err := s.store.SaveWithEntries("wallet", walletData, entry("spent", identity, []byte("true"))); err != nil {
return fmt.Errorf("player: persist equipment option reroll wallet: %w", err)
}
if err := s.inventory.commitOwned(nextItems); err != nil {
return fmt.Errorf("player: persist equipment option reroll items: %w", err)
}
if err := s.persistEquipmentState(s.owned, pending, true, "option reroll"); err != nil {
return err
}
s.wallet.state = nextWallet
s.inventory.owned = nextItems
s.pendingReroll = &equipmentOptionRerollPending{Equipment: cloneEquipment(pending.Equipment)}
return nil
}
func upgradeLackItems(costs []gamedata.PromotionCost) []Item {
items := make([]Item, 0, len(costs))
for _, cost := range costs {
items = append(items, Item{ID: cost.ID, Type: cost.Type, Count: cost.Count})
}
return items
}
func (s *EquipmentInventory) equipmentPositionLocked(index uint64) int {
for i := range s.owned.Equipment {
if s.owned.Equipment[i].InvenIndex == index {
return i
}
}
return -1
}
func (s *EquipmentInventory) selectUpgradeCosts(costs []gamedata.PromotionCost) ([]Item, uint64, error) {
var selected []Item
var gold uint64
for _, cost := range costs {
switch cost.Type {
case 4:
if cost.ID != 0 || cost.Count == 0 || gold != 0 {
return nil, 0, errors.New("player: invalid equipment upgrade gold cost")
}
gold = cost.Count
selected = append(selected, Item{Type: 4, Count: cost.Count})
case 8:
items, err := s.inventory.SelectMutable(cost.Type, cost.ID, cost.Count)
if err != nil {
return nil, 0, err
}
selected = append(selected, items...)
default:
return nil, 0, fmt.Errorf("player: unsupported equipment upgrade cost type %d", cost.Type)
}
}
return selected, gold, nil
}
func (s *EquipmentInventory) attemptUpgradeLocked(index uint64, materials []Item) (Equipment, bool, uint64, []Item, error) {
if s.upgrade == nil || s.wallet == nil {
return Equipment{}, false, 0, nil, errors.New("player: equipment upgrade unavailable")
}
position := s.equipmentPositionLocked(index)
if position < 0 {
return Equipment{}, false, 0, nil, fmt.Errorf("player: unknown equipment %d", index)
}
current := s.owned.Equipment[position]
level, _, err := s.upgrade.Level(current.ID, current.Level)
if err != nil {
return Equipment{}, false, 0, nil, err
}
want := make(map[[2]uint64]uint64, len(level.Costs))
for _, cost := range level.Costs {
want[[2]uint64{cost.Type, cost.ID}] += cost.Count
}
got := make(map[[2]uint64]uint64)
var gold uint64
var items []Item
for _, material := range materials {
got[[2]uint64{material.Type, material.ID}] += material.Count
if material.Type == 4 {
if material.InvenIndex != 0 || material.ID != 0 || gold != 0 {
return Equipment{}, false, 0, nil, errors.New("player: invalid equipment upgrade currency")
}
gold = material.Count
} else {
items = append(items, material)
}
}
if len(got) != len(want) {
return Equipment{}, false, 0, nil, fmt.Errorf("player: equipment upgrade material kinds mismatch")
}
for key, count := range want {
if got[key] != count {
return Equipment{}, false, 0, nil, fmt.Errorf("player: equipment upgrade material %d/%d=%d want=%d", key[0], key[1], got[key], count)
}
}
if gold != 0 && !s.wallet.CanSpendGold(gold) {
return Equipment{}, false, 0, nil, errors.New("player: insufficient gold for equipment upgrade")
}
if len(items) != 0 {
if s.inventory == nil {
return Equipment{}, false, 0, nil, errors.New("player: equipment upgrade inventory unavailable")
}
if err := s.inventory.CanConsume(items); err != nil {
return Equipment{}, false, 0, nil, err
}
}
// Draw before any cross-store write: RNG failure must never charge the
// player. Ordinary enhancement also unlocks an official grade at the
// +3/+6/+9 pivots; smelting can later change those grades, but is separate.
success, err := s.upgrade.Roll(level.SuccessRatio)
if err != nil {
return Equipment{}, false, 0, nil, fmt.Errorf("player: roll equipment upgrade: %w", err)
}
var rankSlot, rankValue uint64
if success {
nextLevel := current.Level + 1
switch nextLevel {
case 3:
rankSlot = 1
case 6:
rankSlot = 2
case 9:
rankSlot = 3
}
if rankSlot != 0 {
if len(current.Rank) != 3 || current.Rank[rankSlot-1] != 0 {
return Equipment{}, false, 0, nil, fmt.Errorf("player: equipment %d invalid rank state at +%d", index, nextLevel)
}
rankValue, err = s.upgrade.RollRank(current.ID, rankSlot)
if err != nil {
return Equipment{}, false, 0, nil, fmt.Errorf("player: roll equipment rank: %w", err)
}
}
}
attempt := current.UpgradeAttempts + 1
identity := "equip-upgrade:" + strconv.FormatUint(index, 10) + ":" + strconv.FormatUint(attempt, 10)
if gold != 0 {
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
return Equipment{}, false, 0, nil, fmt.Errorf("player: spend equipment upgrade gold: %w", err)
}
}
if len(items) != 0 {
if err := s.inventory.Consume(items); err != nil {
return Equipment{}, false, 0, nil, fmt.Errorf("player: consume equipment upgrade items: %w", err)
}
}
next := cloneEquipmentSnapshot(s.owned)
next.Equipment[position].UpgradeAttempts = attempt
if success {
next.Equipment[position].Level++
if rankSlot != 0 {
next.Equipment[position].Rank[rankSlot-1] = rankValue
}
}
if err := s.commitLocked(next, "upgrade"); err != nil {
return Equipment{}, false, 0, nil, err
}
return next.Equipment[position], success, gold, materials, nil
}
func (s *EquipmentInventory) equippedCharacter(entry Equipment) (Character, bool) {
if entry.UseChar == 0 || s.characters == nil {
return Character{}, false
}
return s.characters.Find(entry.UseChar)
}
func (s *EquipmentInventory) clear(request []byte) (int, []byte, bool, error) {
equipmentIndex, found, err := wire.Varint(request, 2)
if err != nil || !found || equipmentIndex == 0 {
return 0, nil, true, errors.New("player: EquipClear missing equipment")
}
characterIndex, found, err := wire.Varint(request, 3)
if err != nil || !found || characterIndex == 0 {
return 0, nil, true, errors.New("player: EquipClear missing character")
}
if s.characters == nil {
return 0, nil, true, errors.New("player: EquipClear character store unavailable")
}
if _, found := s.characters.Find(characterIndex); !found {
return 0, nil, true, fmt.Errorf("player: EquipClear unknown character %d", characterIndex)
}
s.mu.Lock()
next := cloneEquipmentSnapshot(s.owned)
position := -1
for i := range next.Equipment {
if next.Equipment[i].InvenIndex == equipmentIndex {
position = i
break
}
}
if position < 0 {
s.mu.Unlock()
return 0, nil, true, fmt.Errorf("player: EquipClear unknown equipment %d", equipmentIndex)
}
if next.Equipment[position].UseChar != characterIndex {
s.mu.Unlock()
return 0, nil, true, fmt.Errorf("player: EquipClear equipment %d is not used by character %d", equipmentIndex, characterIndex)
}
next.Equipment[position].UseChar = 0
if err := s.commitLocked(next, "clear"); err != nil {
s.mu.Unlock()
return 0, nil, true, err
}
s.mu.Unlock()
// Re-query after the equipment mutation so the shared stat calculator
// returns HP with the cleared item excluded.
character, found := s.characters.Find(characterIndex)
if !found {
return 0, nil, true, fmt.Errorf("player: EquipClear character %d disappeared", characterIndex)
}
return 36, wire.AppendBytes(nil, 1, CharacterWire(character)), true, nil
}
func (s *EquipmentInventory) lock(request []byte) (int, []byte, bool, error) {
equipmentIndex, found, err := wire.Varint(request, 2)
if err != nil || !found || equipmentIndex == 0 {
return 0, nil, true, errors.New("player: EquipLock missing equipment")
}
// LockFlag is int32, but proto3 omits its zero value. A missing field 3 is
// therefore the normal unlock request; present values are restricted to 1.
lockFlag, present, err := wire.Varint(request, 3)
if err != nil || (present && lockFlag != 1) {
return 0, nil, true, errors.New("player: EquipLock invalid lock flag")
}
if !present {
lockFlag = 0
}
s.mu.Lock()
defer s.mu.Unlock()
next := cloneEquipmentSnapshot(s.owned)
position := -1
for i := range next.Equipment {
if next.Equipment[i].InvenIndex == equipmentIndex {
position = i
break
}
}
if position < 0 {
return 0, nil, true, fmt.Errorf("player: EquipLock unknown equipment %d", equipmentIndex)
}
next.Equipment[position].LockFlag = lockFlag
if err := s.commitLocked(next, "lock"); err != nil {
return 0, nil, true, err
}
return 38, nil, true, nil
}
func (s *EquipmentInventory) mark(path string, request []byte) (int, []byte, bool, error) {
equipmentIndex, found, err := wire.Varint(request, 2)
if err != nil || !found || equipmentIndex == 0 {
return 0, nil, true, fmt.Errorf("player: %s missing equipment", strings.TrimPrefix(path, "/"))
}
mark := ""
packetCode := 397
if path == "/EquipMarkSet" {
raw, present, fieldErr := wire.Bytes(request, 3)
if fieldErr != nil || !present || !validEquipmentMark(raw) {
return 0, nil, true, errors.New("player: EquipMarkSet invalid mark")
}
mark = string(raw)
packetCode = 396
}
s.mu.Lock()
defer s.mu.Unlock()
next := cloneEquipmentSnapshot(s.owned)
position := -1
for i := range next.Equipment {
if next.Equipment[i].InvenIndex == equipmentIndex {
position = i
break
}
}
if position < 0 {
return 0, nil, true, fmt.Errorf("player: %s unknown equipment %d", strings.TrimPrefix(path, "/"), equipmentIndex)
}
next.Equipment[position].Mark = mark
if err := s.commitLocked(next, strings.TrimPrefix(path, "/")); err != nil {
return 0, nil, true, err
}
return packetCode, nil, true, nil
}
// EquipmentInfo.MakeStringCustomMarkData emits either "~<icon>|<color>"
// (icon 1..15) or a text mark followed by "|<color>". Text input is capped
// by the 2-byte MAXIMUM_CUSTOMMARK_TEXT_SIZE in CustomSettingPopupUI; a
// single character is prefixed with '_' so it cannot be confused with an ID.
func validEquipmentMark(raw []byte) bool {
if len(raw) == 0 || len(raw) > 8 || !utf8.Valid(raw) {
return false
}
parts := strings.Split(string(raw), "|")
if len(parts) != 2 {
return false
}
color, err := strconv.Atoi(parts[1])
if err != nil || color < 0 || color > 5 {
return false
}
left := parts[0]
if strings.HasPrefix(left, "~") {
icon, err := strconv.Atoi(strings.TrimPrefix(left, "~"))
return err == nil && icon >= 1 && icon <= 15
}
if strings.HasPrefix(left, "_") {
text := strings.TrimPrefix(left, "_")
return utf8.RuneCountInString(text) == 1 && len([]byte(text)) <= 2
}
return len(left) >= 1 && len([]byte(left)) <= 2
}
func (s *EquipmentInventory) change(request []byte) (int, []byte, bool, error) {
equipmentIndex, found, err := wire.Varint(request, 2)
if err != nil || !found || equipmentIndex == 0 {
return 0, nil, true, errors.New("player: EquipChange missing equipment")
}
characterIndex, found, err := wire.Varint(request, 3)
if err != nil || !found || characterIndex == 0 {
return 0, nil, true, errors.New("player: EquipChange missing character")
}
if s.characters == nil {
return 0, nil, true, errors.New("player: EquipChange character store unavailable")
}
if _, found := s.characters.Find(characterIndex); !found {
return 0, nil, true, fmt.Errorf("player: EquipChange unknown character %d", characterIndex)
}
s.mu.Lock()
if len(s.slots) == 0 {
s.mu.Unlock()
return 0, nil, true, errors.New("player: EquipChange slot design unavailable")
}
next := cloneEquipmentSnapshot(s.owned)
position := -1
for i := range next.Equipment {
if next.Equipment[i].InvenIndex == equipmentIndex {
position = i
break
}
}
if position < 0 {
s.mu.Unlock()
return 0, nil, true, fmt.Errorf("player: EquipChange unknown equipment %d", equipmentIndex)
}
selected := next.Equipment[position]
slot, exists := s.slots[selected.ID]
if !exists {
s.mu.Unlock()
return 0, nil, true, fmt.Errorf("player: EquipChange equipment design %d not found", selected.ID)
}
if selected.UseChar != 0 && selected.UseChar != characterIndex {
s.mu.Unlock()
return 0, nil, true, fmt.Errorf("player: EquipChange equipment %d belongs to another character", equipmentIndex)
}
replaced := false
for i := range next.Equipment {
current := &next.Equipment[i]
if current.InvenIndex == equipmentIndex || current.UseChar != characterIndex {
continue
}
currentSlot, known := s.slots[current.ID]
if !known {
s.mu.Unlock()
return 0, nil, true, fmt.Errorf("player: EquipChange equipped design %d not found", current.ID)
}
if currentSlot == slot {
current.UseChar = 0
replaced = true
}
}
if !replaced {
s.mu.Unlock()
return 0, nil, true, fmt.Errorf("player: EquipChange character %d has no equipment in slot %d", characterIndex, slot)
}
next.Equipment[position].UseChar = characterIndex
if err := s.commitLocked(next, "change"); err != nil {
s.mu.Unlock()
return 0, nil, true, err
}
s.mu.Unlock()
// Max HP depends on the now-current equipment set, so build CharInfo only
// after the atomic equipment save is visible to the shared stat calculator.
character, found := s.characters.Find(characterIndex)
if !found {
return 0, nil, true, fmt.Errorf("player: EquipChange character %d disappeared", characterIndex)
}
// PacketCodeTypeProto orders EquipChange at 45 (EquipInfo is 34).
return 45, wire.AppendBytes(nil, 1, CharacterWire(character)), true, nil
}
func cloneEquipmentSnapshot(current equipmentSnapshot) equipmentSnapshot {
next := equipmentSnapshot{Version: current.Version, NextIndex: current.NextIndex,
Equipment: append([]Equipment(nil), current.Equipment...), Granted: make(map[string]uint64, len(current.Granted))}
for i := range next.Equipment {
next.Equipment[i] = cloneEquipment(next.Equipment[i])
}
for key, value := range current.Granted {
next.Granted[key] = value
}
return next
}
func cloneEquipment(current Equipment) Equipment {
next := current
next.MainOption = append([]EquipmentOption(nil), current.MainOption...)
next.SubOption = append([]EquipmentOption(nil), current.SubOption...)
next.Rank = append([]uint64(nil), current.Rank...)
if current.PrivateOption != nil {
option := *current.PrivateOption
next.PrivateOption = &option
}
return next
}
func (s *EquipmentInventory) validatePendingRerollLocked(pending *equipmentOptionRerollPending) error {
if pending == nil || pending.Equipment.InvenIndex == 0 || pending.Equipment.ID == 0 {
return errors.New("player: invalid equipment option reroll candidate")
}
position := s.equipmentPositionLocked(pending.Equipment.InvenIndex)
if position < 0 {
return fmt.Errorf("player: option reroll candidate references unknown equipment %d", pending.Equipment.InvenIndex)
}
current := s.owned.Equipment[position]
if len(pending.Equipment.MainOption) != len(current.MainOption) || len(pending.Equipment.SubOption) != len(current.SubOption) {
return errors.New("player: option reroll candidate option counts do not match equipment")
}
for i, option := range pending.Equipment.MainOption {
if option.GroupID != current.MainOption[i].GroupID || option.ID == 0 {
return fmt.Errorf("player: option reroll candidate main option %d is invalid", i)
}
}
for i, option := range pending.Equipment.SubOption {
if option.GroupID != current.SubOption[i].GroupID || option.ID == 0 {
return fmt.Errorf("player: option reroll candidate sub option %d is invalid", i)
}
}
if s.optionReroll != nil {
definition, ok := s.optionReroll.Lookup(current.ID)
if !ok || len(definition.MainGroups) != len(pending.Equipment.MainOption) || len(definition.SubGroups) != len(pending.Equipment.SubOption) {
return errors.New("player: option reroll candidate has no matching GameData design")
}
for i, option := range pending.Equipment.MainOption {
if option.GroupID != definition.MainGroups[i] || !optionChoiceExists(s.optionReroll.Groups[option.GroupID], option.ID) {
return fmt.Errorf("player: option reroll candidate main option %d is not in GameData", i)
}
}
for i, option := range pending.Equipment.SubOption {
if option.GroupID != definition.SubGroups[i] || !optionChoiceExists(s.optionReroll.Groups[option.GroupID], option.ID) {
return fmt.Errorf("player: option reroll candidate sub option %d is not in GameData", i)
}
}
}
invariant := cloneEquipment(pending.Equipment)
invariant.MainOption = append([]EquipmentOption(nil), current.MainOption...)
invariant.SubOption = append([]EquipmentOption(nil), current.SubOption...)
if !reflect.DeepEqual(invariant, current) {
return errors.New("player: option reroll candidate modifies immutable equipment state")
}
return nil
}
func optionChoiceExists(group gamedata.OptionGroup, id uint64) bool {
for _, choice := range group.Choices {
if choice.ID == id {
return true
}
}
return false
}
func (s *EquipmentInventory) commitLocked(next equipmentSnapshot, operation string) error {
if err := s.persistEquipment(next, operation); err != nil {
return err
}
s.owned = next
s.persisted = cloneEquipmentSnapshot(next)
return nil
}
func (s *EquipmentInventory) persistEquipment(next equipmentSnapshot, operation string) error {
return s.persistEquipmentState(next, nil, false, operation)
}
func (s *EquipmentInventory) persistEquipmentState(next equipmentSnapshot, pending *equipmentOptionRerollPending, pendingDirty bool, operation string) error {
data, err := json.Marshal(next)
if err != nil {
return err
}
changes := make([]stateio.EntryMutation, 0)
for key, value := range next.Granted {
if value != 0 && s.persisted.Granted[key] != value {
changes = append(changes, stateio.EntryMutation{Bucket: "granted", Key: key, Payload: []byte(strconv.FormatUint(value, 10))})
}
}
before := make(map[uint64]Equipment, len(s.persisted.Equipment))
for _, item := range s.persisted.Equipment {
before[item.InvenIndex] = item
}
for _, item := range next.Equipment {
old, exists := before[item.InvenIndex]
if !exists || !reflect.DeepEqual(old, item) {
payload, err := json.Marshal(item)
if err != nil {
return err
}
changes = append(changes, stateio.EntryMutation{Bucket: "equipment", Key: strconv.FormatUint(item.InvenIndex, 10), Payload: payload})
}
delete(before, item.InvenIndex)
}
for index := range before {
changes = append(changes, stateio.EntryMutation{Bucket: "equipment", Key: strconv.FormatUint(index, 10), Delete: true})
}
if pendingDirty {
change := stateio.EntryMutation{Bucket: "reroll_pending", Key: "current", Delete: pending == nil}
if pending != nil {
change.Payload, err = json.Marshal(pending)
if err != nil {
return err
}
}
changes = append(changes, change)
}
if s.corePresent && next.Version == s.persisted.Version && next.NextIndex == s.persisted.NextIndex {
data = nil
}
if err := s.store.SaveWithEntries("equipment", data, changes); err != nil {
return fmt.Errorf("player: persist equipment %s: %w", operation, err)
}
s.corePresent = true
return nil
}
func (s *EquipmentInventory) All() []Equipment {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Equipment(nil), s.owned.Equipment...)
}
// Granted returns a prior idempotent grant without creating an item.
func (s *EquipmentInventory) Granted(identity string) (Equipment, bool) {
s.mu.Lock()
defer s.mu.Unlock()
index := s.owned.Granted[identity]
if index == 0 {
return Equipment{}, false
}
for _, entry := range s.owned.Equipment {
if entry.InvenIndex == index {
return entry, true
}
}
return Equipment{}, false
}
func (s *EquipmentInventory) use(request []byte) (int, []byte, bool, error) {
equipmentIndex, found, err := wire.Varint(request, 2)
if err != nil || !found || equipmentIndex == 0 {
return 0, nil, true, errors.New("player: EquipUse missing equipment")
}
characterIndex, found, err := wire.Varint(request, 3)
if err != nil || !found || characterIndex == 0 {
return 0, nil, true, errors.New("player: EquipUse missing character")
}
if s.characters == nil {
return 0, nil, true, errors.New("player: EquipUse character store unavailable")
}
// Find computes account buffs and may query EquipmentInventory.All(). Do
// not hold the equipment mutex while resolving the character's stats.
character, found := s.characters.Find(characterIndex)
if !found {
return 0, nil, true, fmt.Errorf("player: EquipUse unknown character %d", characterIndex)
}
s.mu.Lock()
defer s.mu.Unlock()
next := equipmentSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex,
Equipment: append([]Equipment(nil), s.owned.Equipment...), Granted: make(map[string]uint64, len(s.owned.Granted))}
for k, v := range s.owned.Granted {
next.Granted[k] = v
}
position := -1
for i, current := range next.Equipment {
if current.InvenIndex == equipmentIndex {
position = i
break
}
}
if position < 0 {
return 0, nil, true, fmt.Errorf("player: EquipUse unknown equipment %d", equipmentIndex)
}
next.Equipment[position].UseChar = characterIndex
if err := s.commitLocked(next, "use"); err != nil {
return 0, nil, true, fmt.Errorf("player: persist equipment use: %w", err)
}
return 35, wire.AppendBytes(nil, 1, CharacterWire(character)), true, nil
}