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