Files
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

364 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
dnfequip "longheng.io/server/internal/modules/dnf/equip"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 本文件补齐 GmTool90CN 的装备穿脱能力:
//
// POST /local/equipment-move action=equip | unequip
//
// 为什么不自己搬槽位
// 穿脱装备有大量规则:部位匹配、职业/成长类型门槛、双武器副手限制、
// 装扮与克隆装扮的外观投影、封印解除、宠物/神器聚合……这些只在
// equip 模块里实现过一份。所以这里复用 dnfequip.Owner.Move,
// 并使用服务端真实的 placement validator —— 和游戏内 op19 走同一条路。
//
// 「脱下」方向服务端已有 GMUnequipEquipment(含真实落点规则),这里不重复实现,
// 只补「穿上」。
var (
// ErrGMEquipmentActionUnsupported 表示 action 不是 equip/unequip。
ErrGMEquipmentActionUnsupported = errors.New("gm equipment action is unsupported")
)
// GMEquipmentMoveRequest 对应 POST /local/equipment-move。
//
// CharacterID 用 json.RawMessage:GmTool90CN 发的是**数字**(它把 uint16 直接
// 序列化进 JSON),而其余 admin 接口传字符串。两者都要接受,否则工具端会收到
// "cannot unmarshal number into Go struct field ... of type string"。
type GMEquipmentMoveRequest struct {
CharacterID json.RawMessage `json:"character_id"`
Action string `json:"action"`
// 道具当前所在容器与槽位(action=equip 时使用)。
InventoryListType *int `json:"inventory_list_type,omitempty"`
InventorySlot *int `json:"inventory_slot,omitempty"`
// 目标穿戴槽位(action=unequip 时使用)。
WornSlot *int `json:"worn_slot,omitempty"`
// 期望的道具 id:不为 0 时校验,防止工具端界面过期后搬错东西。
ExpectedItemID int64 `json:"expected_item_id,omitempty"`
Confirmation string `json:"confirmation,omitempty"`
}
// GMEquipmentMoveResult 描述一次穿脱装备的结果。
type GMEquipmentMoveResult struct {
Success bool `json:"success"`
CharacterID string `json:"character_id"`
Action string `json:"action"`
Mode string `json:"mode"`
ItemID int64 `json:"item_id"`
SwappedItemID int64 `json:"swapped_item_id"`
SourceListType int `json:"source_list_type"`
SourceSlot int `json:"source_slot"`
DestinationListType int `json:"destination_list_type"`
DestinationSlot int `json:"destination_slot"`
Changed bool `json:"changed"`
Online bool `json:"online"`
RefreshSent bool `json:"refresh_sent"`
RefreshDeferred bool `json:"refresh_deferred"`
RefreshError string `json:"refresh_error,omitempty"`
}
const (
gmEquipmentModeOffline = "offline_persist"
gmEquipmentActionEquip = "equip"
gmEquipmentActionStrip = "unequip"
)
// GMEquipmentMove 把一个角色的道具在背包与穿戴槽之间搬动。
func (s *Service) GMEquipmentMove(ctx context.Context, request GMEquipmentMoveRequest) (GMEquipmentMoveResult, error) {
result := GMEquipmentMoveResult{}
if s == nil {
return result, ErrGMUnavailable
}
if ctx == nil {
ctx = context.Background()
}
characterID, err := gmEquipmentCharacterID(request.CharacterID)
if err != nil {
return result, err
}
action := strings.ToLower(strings.TrimSpace(request.Action))
if action != gmEquipmentActionEquip && action != gmEquipmentActionStrip {
return result, fmt.Errorf("%w: action=%q (expected equip/unequip)", ErrGMEquipmentActionUnsupported, action)
}
result.CharacterID = characterID
result.Action = action
result.Mode = gmEquipmentModeOffline
repositories, available := s.repositoryGroup()
if !available || repositories.CharacterItems == nil {
return result, ErrGMRepositoryUnavailable
}
numericID, err := gmNumericCharacterID(characterID)
if err != nil {
return result, err
}
character, err := s.gmCharacter(ctx, repositories, characterID, "")
if err != nil {
return result, err
}
if _, online := s.onlineGameSession(numericID); online {
result.Online = true
}
if action == gmEquipmentActionStrip {
if request.WornSlot == nil {
return result, fmt.Errorf("%w: worn_slot is required for unequip", ErrGMRequestInvalid)
}
slot := *request.WornSlot
if slot < 0 || slot > 255 {
return result, fmt.Errorf("%w: worn_slot out of range", ErrGMRequestInvalid)
}
moveResult, moveErr := s.gmEquipmentMoveUnequip(ctx, repositories, character, numericID, int16(slot), request.ExpectedItemID)
if moveErr != nil {
return result, moveErr
}
fillGMEquipmentMoveResult(&result, moveResult)
return result, nil
}
if request.InventoryListType == nil || request.InventorySlot == nil {
return result, fmt.Errorf("%w: inventory_list_type and inventory_slot are required for equip", ErrGMRequestInvalid)
}
listType := *request.InventoryListType
slot := *request.InventorySlot
if listType < 0 || listType > 255 || slot < 0 {
return result, fmt.Errorf("%w: inventory list/slot out of range", ErrGMRequestInvalid)
}
moveResult, moveErr := s.gmEquipmentMoveEquip(ctx, repositories, character, numericID, byte(listType), int16(slot), request.ExpectedItemID)
if moveErr != nil {
return result, moveErr
}
fillGMEquipmentMoveResult(&result, moveResult)
return result, nil
}
// gmEquipmentMoveEquip 把背包/装扮容器里的道具穿到对应部位。
//
// 目标穿戴槽由 PVF 的 [equipment type] 决定,而不是让调用方指定:
// 工具端界面本身也传 worn_slot=-1 表示"你来决定部位"。这样部位匹配规则只有
// 一份(currentEquipmentPlacementRuleForPVFType),不会和游戏内分叉。
func (s *Service) gmEquipmentMoveEquip(
ctx context.Context,
repositories dnfrepo.Group,
character dnfrepo.CharacterRecord,
numericID uint16,
sourceListType byte,
sourceSlot int16,
expectedItemID int64,
) (dnfequip.MoveResult, error) {
stack, err := gmLoadInventoryStack(ctx, repositories, character.CharacterID, sourceListType, sourceSlot)
if err != nil {
return dnfequip.MoveResult{}, err
}
if stack.ItemID <= 0 {
return dnfequip.MoveResult{}, fmt.Errorf("%w: slot %d:%d is empty", ErrGMRequestInvalid, sourceListType, sourceSlot)
}
if expectedItemID > 0 && stack.ItemID != expectedItemID {
return dnfequip.MoveResult{}, fmt.Errorf(
"%w: expected item=%d but slot %d:%d holds %d",
ErrGMRequestInvalid, expectedItemID, sourceListType, sourceSlot, stack.ItemID)
}
targetSlot, err := s.gmEquipmentTargetWornSlot(ctx, repositories, character.AccountID, numericID, stack.ItemID)
if err != nil {
return dnfequip.MoveResult{}, err
}
validator, err := s.newCurrentEquipmentPlacementValidator(
ctx, character.AccountID, numericID, repositories)
if err != nil {
// 放置校验器建不起来意味着这件装备没法按 PVF 规则判部位/资格,
// 直接失败比穿上一件位置错误的装备安全。
return dnfequip.MoveResult{}, fmt.Errorf("%w: prepare equipment placement validator: %v", ErrGMRequestInvalid, err)
}
owner, err := dnfequip.NewOwnerWithPlacementValidator(repositories, validator)
if err != nil {
return dnfequip.MoveResult{}, fmt.Errorf("%w: %v", ErrGMUnavailable, err)
}
// DestinationSlotIndex = 目标部位:该部位已有装备时,equip 模块会把它交换回
// 源槽位,正是工具端提示的"自动交换回当前背包槽位"。
return owner.Move(ctx, dnfequip.MoveCommand{
SelectedCharacterID: numericID,
SourceListType: sourceListType,
SourceSlotIndex: sourceSlot,
MoveCount: 1,
DestinationListType: gmEquipmentListWorn,
DestinationSlotIndex: targetSlot,
})
}
// gmEquipmentTargetWornSlot 按 PVF 的 [equipment type] 求出该装备的穿戴槽位。
func (s *Service) gmEquipmentTargetWornSlot(
ctx context.Context,
repositories dnfrepo.Group,
accountID string,
numericID uint16,
itemID int64,
) (int16, error) {
validator, err := s.newCurrentEquipmentPlacementValidator(ctx, accountID, numericID, repositories)
if err != nil {
return 0, fmt.Errorf("%w: prepare equipment placement validator: %v", ErrGMRequestInvalid, err)
}
// 工厂返回接口,但求部位要用它内部的 PVF 索引与归档,所以断言回具体类型。
concrete, ok := validator.(*currentEquipmentPlacementValidator)
if !ok || concrete == nil {
return 0, fmt.Errorf("%w: unexpected equipment placement validator", ErrGMRequestInvalid)
}
rule, err := concrete.validatePVFItem(itemID)
if err != nil {
return 0, fmt.Errorf("%w: item=%d is not wearable equipment: %v", ErrGMRequestInvalid, itemID, err)
}
if strings.TrimSpace(rule.pvfPath) == "" {
return 0, fmt.Errorf("%w: item=%d has no PVF path", ErrGMRequestInvalid, itemID)
}
text, actualPath, err := readInitialPVFText(
concrete.pvfSource, initialPVFPath("equipment", rule.pvfPath))
if err != nil {
return 0, fmt.Errorf("%w: item=%d path=%q: %v", ErrGMRequestInvalid, itemID, rule.pvfPath, err)
}
doc, err := dnfpvf.Parse(actualPath, text)
if err != nil {
return 0, fmt.Errorf("%w: item=%d path=%q: %v", ErrGMRequestInvalid, itemID, actualPath, err)
}
pvfType, ok := doc.Text("equipment type")
if !ok {
return 0, fmt.Errorf("%w: item=%d path=%q missing [equipment type]", ErrGMRequestInvalid, itemID, actualPath)
}
resolved, ok := currentEquipmentPlacementRuleForPVFType(pvfType)
if !ok {
return 0, fmt.Errorf("%w: item=%d has unsupported equipment type %q", ErrGMRequestInvalid, itemID, pvfType)
}
if resolved.targetSlot < 0 {
return 0, fmt.Errorf("%w: item=%d type %q has no worn slot", ErrGMRequestInvalid, itemID, pvfType)
}
return resolved.targetSlot, nil
}
// gmEquipmentMoveUnequip 复用服务端已有的 GMUnequipEquipment(含真实落点规则)。
func (s *Service) gmEquipmentMoveUnequip(
ctx context.Context,
repositories dnfrepo.Group,
character dnfrepo.CharacterRecord,
numericID uint16,
wornSlot int16,
expectedItemID int64,
) (dnfequip.MoveResult, error) {
if expectedItemID > 0 {
record, found, loadErr := repositories.Equipment.Load(ctx, character.CharacterID)
if loadErr != nil {
return dnfequip.MoveResult{}, loadErr
}
if !found {
return dnfequip.MoveResult{}, fmt.Errorf("%w: equipment record is missing", ErrGMRequestInvalid)
}
entry, present := record.Entries[fmt.Sprintf("%d", wornSlot)]
if !present {
return dnfequip.MoveResult{}, fmt.Errorf("%w: worn slot %d is empty", ErrGMRequestInvalid, wornSlot)
}
if entry.ItemID != expectedItemID {
return dnfequip.MoveResult{}, fmt.Errorf(
"%w: expected item=%d but worn slot %d holds %d",
ErrGMRequestInvalid, expectedItemID, wornSlot, entry.ItemID)
}
}
destinationList, destinationSlot, err := gmEquipmentUnequipDestination(wornSlot)
if err != nil {
return dnfequip.MoveResult{}, err
}
validator := dnfequip.PlacementValidatorFunc(func(context.Context, dnfequip.Placement) error {
return fmt.Errorf("%w: unequip unexpectedly attempted an equipment placement", ErrGMRequestInvalid)
})
owner, err := dnfequip.NewOwnerWithPlacementValidator(repositories, validator)
if err != nil {
return dnfequip.MoveResult{}, fmt.Errorf("%w: %v", ErrGMUnavailable, err)
}
return owner.Move(ctx, dnfequip.MoveCommand{
SelectedCharacterID: numericID,
SourceListType: gmEquipmentListWorn,
SourceSlotIndex: wornSlot,
MoveCount: 1,
DestinationListType: destinationList,
DestinationSlotIndex: destinationSlot,
})
}
// gmLoadInventoryStack 读一条背包行(用于 expected_item_id 校验)。
func gmLoadInventoryStack(
ctx context.Context,
repositories dnfrepo.Group,
characterID string,
listType byte,
slot int16,
) (dnfrepo.ItemStack, error) {
if repositories.Inventory == nil {
return dnfrepo.ItemStack{}, ErrGMRepositoryUnavailable
}
record, found, err := repositories.Inventory.Load(ctx, characterID)
if err != nil {
return dnfrepo.ItemStack{}, err
}
if !found {
return dnfrepo.ItemStack{}, fmt.Errorf("%w: character=%s", ErrGMInventoryNotFound, characterID)
}
key := fmt.Sprintf("%d:%d", listType, slot)
stack, present := record.Slots[key]
if !present {
return dnfrepo.ItemStack{}, fmt.Errorf("%w: entry_key=%s", ErrGMItemRowNotFound, key)
}
return stack, nil
}
// fillGMEquipmentMoveResult 把 equip 模块的 MoveResult 投影到 admin 响应。
func fillGMEquipmentMoveResult(result *GMEquipmentMoveResult, move dnfequip.MoveResult) {
if result == nil {
return
}
result.Success = true
result.Changed = move.Changed
if move.CharacterID != "" {
result.CharacterID = move.CharacterID
}
result.ItemID = move.ItemID
result.SwappedItemID = move.SwappedItemID
result.SourceListType = int(move.SourceListType)
result.SourceSlot = int(move.SourceSlotIndex)
result.DestinationListType = int(move.DestinationListType)
result.DestinationSlot = int(move.DestinationSlotIndex)
if result.Mode == "" {
result.Mode = gmEquipmentModeOffline
}
}
// gmEquipmentCharacterID 接受 JSON 数字或字符串形式的 character_id。
func gmEquipmentCharacterID(raw json.RawMessage) (string, error) {
text := strings.Trim(strings.TrimSpace(string(raw)), `"`)
if text == "" || text == "null" {
return "", fmt.Errorf("%w: character_id is required", ErrGMRequestInvalid)
}
return text, nil
}
// gmNumericCharacterID 把仓储里的 character_id 文本转成 uint16。
func gmNumericCharacterID(characterID string) (uint16, error) {
value, err := parseGMCharacterID(characterID)
if err != nil {
return 0, fmt.Errorf("%w: character_id=%q", ErrGMRequestInvalid, characterID)
}
return value, nil
}
// gmEquipmentMutationClock 是本文件里唯一的时间来源,便于测试替换。
func gmEquipmentMutationClock() time.Time { return time.Now().UTC() }