服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
296 lines
9.0 KiB
Go
296 lines
9.0 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const gmLoadoutCooldown = 10 * time.Second
|
|
|
|
var gmLoadoutCooldownStore = struct {
|
|
sync.Mutex
|
|
last map[string]time.Time
|
|
}{last: make(map[string]time.Time)}
|
|
|
|
// GMLoadoutSlot is one wearable target saved by the external loadout tool.
|
|
type GMLoadoutSlot struct {
|
|
Slot int `json:"slot"`
|
|
ItemID int64 `json:"item_id"`
|
|
}
|
|
|
|
type GMLoadoutSnapshot struct {
|
|
CharacterID string `json:"character_id"`
|
|
CharacterName string `json:"character_name"`
|
|
Online bool `json:"online"`
|
|
Slots []GMLoadoutSlot `json:"slots"`
|
|
}
|
|
|
|
type GMLoadoutSkip struct {
|
|
Slot int `json:"slot"`
|
|
ItemID int64 `json:"item_id"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
type GMLoadoutApplyResult struct {
|
|
CharacterID string `json:"character_id"`
|
|
CharacterName string `json:"character_name"`
|
|
Online bool `json:"online"`
|
|
Applied []GMLoadoutSlot `json:"applied"`
|
|
Skipped []GMLoadoutSkip `json:"skipped"`
|
|
RefreshSent bool `json:"refresh_sent"`
|
|
RefreshErr string `json:"refresh_error,omitempty"`
|
|
CooldownSeconds int `json:"cooldown_seconds"`
|
|
CooldownRemaining int `json:"cooldown_remaining"`
|
|
}
|
|
|
|
// The loadout tool manages exactly the ordinary combat equipment:
|
|
// five armor slots, weapon, title, three jewelry slots and three special slots.
|
|
// Avatar, creature, artifact, support-weapon and medal rows are deliberately excluded.
|
|
func gmLoadoutSlotAllowed(slot int) bool {
|
|
switch slot {
|
|
case 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 25:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *Service) gmLoadoutResolveCharacter(ctx context.Context, repositories dnfrepo.Group, input string) (dnfrepo.CharacterRecord, error) {
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return dnfrepo.CharacterRecord{}, fmt.Errorf("%w: character name is required", ErrGMRequestInvalid)
|
|
}
|
|
if _, err := strconv.ParseUint(input, 10, 16); err == nil {
|
|
return s.gmCharacter(ctx, repositories, input, "")
|
|
}
|
|
return s.gmCharacter(ctx, repositories, "", input)
|
|
}
|
|
|
|
// GMLoadoutSnapshot returns the currently worn combat equipment slots.
|
|
func (s *Service) GMLoadoutSnapshot(ctx context.Context, characterName string) (GMLoadoutSnapshot, error) {
|
|
result := GMLoadoutSnapshot{}
|
|
if s == nil {
|
|
return result, ErrGMUnavailable
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Equipment == nil || repositories.Character == nil {
|
|
return result, ErrGMRepositoryUnavailable
|
|
}
|
|
character, err := s.gmLoadoutResolveCharacter(ctx, repositories, characterName)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
result.CharacterID = character.CharacterID
|
|
result.CharacterName = character.Name
|
|
numericID, err := gmNumericCharacterID(character.CharacterID)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if _, online := s.onlineGameSession(numericID); online {
|
|
result.Online = true
|
|
}
|
|
equipment, found, err := repositories.Equipment.Load(ctx, character.CharacterID)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if !found {
|
|
return result, nil
|
|
}
|
|
result.Slots = make([]GMLoadoutSlot, 0, 13)
|
|
for _, entry := range equipment.Entries {
|
|
if entry.ItemID <= 0 || !gmLoadoutSlotAllowed(int(entry.SlotIndex)) {
|
|
continue
|
|
}
|
|
result.Slots = append(result.Slots, GMLoadoutSlot{
|
|
Slot: int(entry.SlotIndex), ItemID: entry.ItemID,
|
|
})
|
|
}
|
|
sortGMLoadoutSlots(result.Slots)
|
|
return result, nil
|
|
}
|
|
|
|
// GMLoadoutApply equips the saved item IDs from the character's main inventory.
|
|
// Empty/disallowed slots and missing items are skipped explicitly.
|
|
func (s *Service) GMLoadoutApply(ctx context.Context, characterName string, slots []GMLoadoutSlot) (GMLoadoutApplyResult, error) {
|
|
result := GMLoadoutApplyResult{}
|
|
if s == nil {
|
|
return result, ErrGMUnavailable
|
|
}
|
|
if len(slots) > 13 {
|
|
return result, fmt.Errorf("%w: too many slots", ErrGMRequestInvalid)
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.CharacterItems == nil {
|
|
return result, ErrGMRepositoryUnavailable
|
|
}
|
|
character, err := s.gmLoadoutResolveCharacter(ctx, repositories, characterName)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
result.CharacterID = character.CharacterID
|
|
result.CharacterName = character.Name
|
|
if remaining := gmLoadoutCooldownRemaining(character.CharacterID); remaining > 0 {
|
|
result.CooldownRemaining = int(remaining/time.Second) + 1
|
|
return result, fmt.Errorf("equipment change cooldown: %d seconds remaining", result.CooldownRemaining)
|
|
}
|
|
numericID, err := gmNumericCharacterID(character.CharacterID)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
session, online := s.onlineGameSession(numericID)
|
|
result.Online = online
|
|
|
|
changed := false
|
|
for _, requested := range slots {
|
|
if !gmLoadoutSlotAllowed(requested.Slot) || requested.ItemID <= 0 {
|
|
result.Skipped = append(result.Skipped, GMLoadoutSkip{
|
|
Slot: requested.Slot, ItemID: requested.ItemID, Reason: "not a loadout equipment slot",
|
|
})
|
|
continue
|
|
}
|
|
equipment, found, loadErr := repositories.Equipment.Load(ctx, character.CharacterID)
|
|
if loadErr != nil {
|
|
return result, loadErr
|
|
}
|
|
if found {
|
|
if worn, ok := equipment.Entries[strconv.Itoa(requested.Slot)]; ok && worn.ItemID == requested.ItemID {
|
|
result.Applied = append(result.Applied, requested)
|
|
continue
|
|
}
|
|
}
|
|
sourceList, sourceSlot, findErr := gmLoadoutFindInventoryItem(ctx, repositories, character.CharacterID, requested.ItemID)
|
|
if findErr != nil {
|
|
result.Skipped = append(result.Skipped, GMLoadoutSkip{
|
|
Slot: requested.Slot, ItemID: requested.ItemID, Reason: findErr.Error(),
|
|
})
|
|
continue
|
|
}
|
|
if sourceSlot < 0 {
|
|
result.Skipped = append(result.Skipped, GMLoadoutSkip{
|
|
Slot: requested.Slot, ItemID: requested.ItemID, Reason: "item not in inventory or worn",
|
|
})
|
|
continue
|
|
}
|
|
move, moveErr := s.GMEquipmentMove(ctx, GMEquipmentMoveRequest{
|
|
CharacterID: json.RawMessage(strconv.Quote(character.CharacterID)),
|
|
Action: gmEquipmentActionEquip,
|
|
InventoryListType: &sourceList,
|
|
InventorySlot: &sourceSlot,
|
|
ExpectedItemID: requested.ItemID,
|
|
})
|
|
if moveErr != nil {
|
|
result.Skipped = append(result.Skipped, GMLoadoutSkip{
|
|
Slot: requested.Slot, ItemID: requested.ItemID, Reason: moveErr.Error(),
|
|
})
|
|
continue
|
|
}
|
|
if move.DestinationSlot != 0 && int(move.DestinationSlot) != requested.Slot {
|
|
result.Skipped = append(result.Skipped, GMLoadoutSkip{
|
|
Slot: requested.Slot, ItemID: requested.ItemID, Reason: "PVF target slot mismatch",
|
|
})
|
|
continue
|
|
}
|
|
changed = true
|
|
result.Applied = append(result.Applied, requested)
|
|
}
|
|
|
|
if changed {
|
|
gmLoadoutMarkCooldown(character.CharacterID)
|
|
result.CooldownSeconds = int(gmLoadoutCooldown / time.Second)
|
|
}
|
|
|
|
if online && session != nil {
|
|
if refreshErr := s.sendSelectedActorExpandedEquipmentRefresh(session, "gm_loadout"); refreshErr != nil {
|
|
result.RefreshErr = refreshErr.Error()
|
|
} else if refreshErr := s.sendSelectedCurrentItemListsRefresh(session, "gm_loadout"); refreshErr != nil {
|
|
result.RefreshErr = refreshErr.Error()
|
|
} else {
|
|
result.RefreshSent = true
|
|
}
|
|
}
|
|
sortGMLoadoutSlots(result.Applied)
|
|
return result, nil
|
|
}
|
|
|
|
func gmLoadoutFindInventoryItem(ctx context.Context, repositories dnfrepo.Group, characterID string, itemID int64) (int, int, error) {
|
|
if repositories.Inventory == nil {
|
|
return 0, -1, fmt.Errorf("inventory repository is unavailable")
|
|
}
|
|
inventory, found, err := repositories.Inventory.Load(ctx, characterID)
|
|
if err != nil {
|
|
return 0, -1, err
|
|
}
|
|
if !found {
|
|
return 0, -1, fmt.Errorf("inventory not found")
|
|
}
|
|
bestSlot := -1
|
|
for key, stack := range inventory.Slots {
|
|
if stack.ItemID != itemID || stack.Count <= 0 {
|
|
continue
|
|
}
|
|
list, slot, ok := parseGMInventoryKey(key)
|
|
if !ok || list != 0 {
|
|
continue
|
|
}
|
|
if bestSlot < 0 || slot < bestSlot {
|
|
bestSlot = slot
|
|
}
|
|
}
|
|
if bestSlot < 0 {
|
|
return 0, -1, nil
|
|
}
|
|
return 0, bestSlot, nil
|
|
}
|
|
|
|
func parseGMInventoryKey(key string) (int, int, bool) {
|
|
parts := strings.Split(strings.TrimSpace(key), ":")
|
|
if len(parts) != 2 {
|
|
return 0, 0, false
|
|
}
|
|
list, err := strconv.ParseInt(parts[0], 10, 32)
|
|
if err != nil {
|
|
return 0, 0, false
|
|
}
|
|
slot, err := strconv.ParseInt(parts[1], 10, 32)
|
|
if err != nil {
|
|
return 0, 0, false
|
|
}
|
|
return int(list), int(slot), true
|
|
}
|
|
|
|
func sortGMLoadoutSlots(slots []GMLoadoutSlot) {
|
|
for i := 1; i < len(slots); i++ {
|
|
for j := i; j > 0 && slots[j].Slot < slots[j-1].Slot; j-- {
|
|
slots[j], slots[j-1] = slots[j-1], slots[j]
|
|
}
|
|
}
|
|
}
|
|
|
|
func gmLoadoutCooldownRemaining(characterID string) time.Duration {
|
|
gmLoadoutCooldownStore.Lock()
|
|
defer gmLoadoutCooldownStore.Unlock()
|
|
last, ok := gmLoadoutCooldownStore.last[characterID]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
remaining := gmLoadoutCooldown - time.Since(last)
|
|
if remaining <= 0 {
|
|
delete(gmLoadoutCooldownStore.last, characterID)
|
|
return 0
|
|
}
|
|
return remaining
|
|
}
|
|
|
|
func gmLoadoutMarkCooldown(characterID string) {
|
|
gmLoadoutCooldownStore.Lock()
|
|
gmLoadoutCooldownStore.last[characterID] = time.Now()
|
|
gmLoadoutCooldownStore.Unlock()
|
|
}
|