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