Files
90CNServer/server/internal/services/dnfbridge/gm_loadout.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

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()
}