Files
bd2/go/internal/player/equipment.go
T
2026-09-21 09:53:46 +08:00

303 lines
9.2 KiB
Go

package player
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"bd2server/internal/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"`
MainOption []EquipmentOption `json:"main_option,omitempty"`
SubOption []EquipmentOption `json:"sub_option,omitempty"`
PrivateOption *EquipmentOption `json:"private_option,omitempty"`
Rank []uint64 `json:"rank,omitempty"`
}
// 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:"equipment"`
Granted map[string]uint64 `json:"granted"`
}
// EquipmentInventory persists non-stackable equipment independently from
// ItemDBInfo inventory because the wire protocols are different types.
type EquipmentInventory struct {
mu sync.Mutex
path string
owned equipmentSnapshot
characters *CharacterStore
}
func (s *EquipmentInventory) AttachCharacters(characters *CharacterStore) error {
if characters == nil {
return errors.New("player: nil character store")
}
s.mu.Lock()
defer s.mu.Unlock()
s.characters = characters
return nil
}
func OpenEquipmentInventory(path string) (*EquipmentInventory, error) {
s := &EquipmentInventory{path: filepath.Clean(path), owned: equipmentSnapshot{
Version: "2.34.13", NextIndex: 910000001, Granted: map[string]uint64{},
}}
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, &s.owned); err != nil {
return nil, fmt.Errorf("player: decode equipment: %w", err)
}
if s.owned.Version != "2.34.13" || s.owned.NextIndex < 910000001 || s.owned.Granted == nil {
return nil, errors.New("player: invalid saved equipment")
}
return s, nil
}
// 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 {
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")
}
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
data, err := json.Marshal(next)
if err != nil {
return Equipment{}, err
}
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return Equipment{}, err
}
f, err := os.CreateTemp(dir, ".equipment-*.tmp")
if err != nil {
return Equipment{}, err
}
defer os.Remove(f.Name())
if _, err = f.Write(data); err == nil {
err = f.Sync()
}
if closeErr := f.Close(); err == nil {
err = closeErr
}
if err == nil {
err = os.Rename(f.Name(), s.path)
}
if err != nil {
return Equipment{}, fmt.Errorf("player: persist equipment: %w", err)
}
s.owned = next
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))
}
if entry.PrivateOption != nil {
base = wire.AppendBytes(base, 5, equipmentOptionWire(*entry.PrivateOption))
}
for _, rank := range entry.Rank {
if rank != 0 {
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)
}
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" {
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)
}
s.mu.Lock()
defer s.mu.Unlock()
var response []byte
for _, entry := range s.owned.Equipment {
response = wire.AppendBytes(response, 1, EquipmentWire(entry))
}
return 34, response, true, 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
data, err := json.Marshal(next)
if err != nil {
return 0, nil, true, err
}
dir := filepath.Dir(s.path)
f, err := os.CreateTemp(dir, ".equipment-*.tmp")
if err != nil {
return 0, nil, true, err
}
defer os.Remove(f.Name())
if _, err = f.Write(data); err == nil {
err = f.Sync()
}
if closeErr := f.Close(); err == nil {
err = closeErr
}
if err == nil {
err = os.Rename(f.Name(), s.path)
}
if err != nil {
return 0, nil, true, fmt.Errorf("player: persist equipment use: %w", err)
}
s.owned = next
return 35, wire.AppendBytes(nil, 1, CharacterWire(character)), true, nil
}