using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using BetterGenshinImpact.GameTask; using BetterGenshinImpact.GameTask.Common.Map.Maps; using BetterGenshinImpact.GameTask.Common.Map.Maps.Base; using BetterGenshinImpact.GameTask.MapMask; using BetterGenshinImpact.Model.MaskMap; using BetterGenshinImpact.Service.Interface; using BetterGenshinImpact.Service.Model.MihoyoMap.Requests; using BetterGenshinImpact.Service.Model.MihoyoMap.Responses; using BetterGenshinImpact.Service.Tavern; using BetterGenshinImpact.Service.Tavern.Model; using LazyCache; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using OpenCvSharp; namespace BetterGenshinImpact.Service; public sealed class MaskMapPointService : IMaskMapPointService { public static readonly TimeSpan CacheDuration = TimeSpan.FromHours(5); // 酒馆(空荧酒馆)点位标签树的第一层 Label 类别定义(固定、手工维护),用于构建第一层节点以及限定第二层归类范围。 private static readonly IReadOnlyList<(long Id, long IconId, string Name)> KongyingFirstLayerLabelDefinitions = new (long Id, long IconId, string Name)[] { (10, 290, "宝箱-品质"), (11, 290, "宝箱-获取"), (2, 291, "见闻"), (3, 292, "特产"), (4, 293, "矿物"), (5, 294, "怪物"), (6, 295, "食材"), (7, 296, "素材"), (8, 297, "家园"), (1, 298, "活动") }; private readonly ILogger _logger; private readonly IAppCache _cache; private readonly IHoYoLabMapApiService _hoyolabMapApi; private readonly IMihoyoMapApiService _mihoyoMapApi; private readonly IKongyingTavernApiService _kongyingTavernApi; public MaskMapPointService( ILogger logger, IAppCache cache, IHoYoLabMapApiService hoyolabMapApi, IMihoyoMapApiService mihoyoMapApi, IKongyingTavernApiService kongyingTavernApi) { _logger = logger; _cache = cache; _hoyolabMapApi = hoyolabMapApi; _mihoyoMapApi = mihoyoMapApi; _kongyingTavernApi = kongyingTavernApi; } public Task> GetLabelCategoriesAsync(CancellationToken ct = default) { return GetProvider() switch { MapPointApiProvider.KongyingTavern => GetKongyingLabelCategoriesAsync(ct), _ => GetMihoyoLabelCategoriesAsync(ct) }; } public Task GetPointsAsync(IReadOnlyList selectedItems, CancellationToken ct = default) { return GetProvider() switch { MapPointApiProvider.KongyingTavern => GetKongyingPointsAsync(selectedItems, ct), _ => GetMihoyoPointsAsync(selectedItems, ct) }; } public Task GetPointInfoAsync(MaskMapPoint point, CancellationToken ct = default) { return GetProvider() switch { MapPointApiProvider.KongyingTavern => GetKongyingPointInfoAsync(point, ct), _ => GetMihoyoPointInfoAsync(point, ct) }; } private static MapPointApiProvider GetProvider() { return TaskContext.Instance().Config.MapMaskConfig.MapPointApiProvider; } private IMihoyoMapApiService GetMihoyoCompatibleApi() { return GetProvider() == MapPointApiProvider.HoYoLab ? _hoyolabMapApi : _mihoyoMapApi; } private async Task> GetMihoyoLabelCategoriesAsync(CancellationToken ct) { ApiResponse? resp = null; try { resp = await GetMihoyoCompatibleApi().GetLabelTreeAsync(new LabelTreeRequest(), ct); } catch (Exception ex) { _logger.LogDebug(ex, "调用米游社地图接口获取点位树失败"); } if (resp == null || resp.Retcode != 0 || resp.Data == null) { resp = TryLoadLabelTreeFromLocalExample(); } if (resp == null || resp.Retcode != 0 || resp.Data == null) { return Array.Empty(); } var categories = resp.Data.Tree .OrderBy(x => x.Sort) .ThenBy(x => x.DisplayPriority) .Select(cat => { var itemsSource = cat.Children != null && cat.Children.Count > 0 ? cat.Children : new List { cat }; var children = itemsSource .OrderBy(x => x.Sort) .ThenBy(x => x.DisplayPriority) .Select(x => new MaskMapPointLabel { LabelId = x.Id.ToString(CultureInfo.InvariantCulture), ParentId = cat.Id.ToString(CultureInfo.InvariantCulture), Name = x.Name, IconUrl = x.Icon, PointCount = x.PointCount }) .ToList(); return new MaskMapPointLabel { LabelId = cat.Id.ToString(CultureInfo.InvariantCulture), Name = cat.Name, IconUrl = cat.Icon, Children = children }; }) .ToList(); return categories; } private async Task GetMihoyoPointsAsync(IReadOnlyList selectedItems, CancellationToken ct) { if (selectedItems.Count == 0) { return new MaskMapPointsResult(); } var selectedSecondLevelIds = selectedItems.Select(x => x.LabelId).ToHashSet(StringComparer.Ordinal); var parentLabelIds = selectedItems .Select(x => x.ParentId) .Where(x => int.TryParse(x, out _)) .Select(x => int.Parse(x, CultureInfo.InvariantCulture)) .Distinct() .OrderBy(x => x) .ToList(); var labels = selectedItems .GroupBy(x => x.LabelId, StringComparer.Ordinal) .Select(g => g.First()) .Select(x => new MaskMapPointLabel { LabelId = x.LabelId, Name = x.Name, IconUrl = x.IconUrl }) .ToList(); if (parentLabelIds.Count == 0) { return new MaskMapPointsResult { Labels = labels, Points = Array.Empty() }; } ApiResponse resp; try { resp = await GetMihoyoPointListCacheAsync(parentLabelIds, ct); } catch (Exception ex) { _logger.LogDebug(ex, "调用米游社地图接口获取点位列表失败"); return new MaskMapPointsResult { Labels = labels, Points = Array.Empty() }; } if (resp.Retcode != 0 || resp.Data == null) { _logger.LogWarning("获取地图点位列表失败: {Retcode} {Message}", resp.Retcode, resp.Message); return new MaskMapPointsResult { Labels = labels, Points = Array.Empty() }; } var map = MapManager.GetMap(MapTypes.Teyvat, TaskContext.Instance().Config.PathingConditionConfig.MapMatchingMethod); var points = resp.Data.PointList .Where(x => selectedSecondLevelIds.Contains(x.LabelId.ToString(CultureInfo.InvariantCulture))) .Select(x => { var m = new MaskMapPoint { Id = x.Id.ToString(CultureInfo.InvariantCulture), X = x.XPos, Y = x.YPos, LabelId = x.LabelId.ToString(CultureInfo.InvariantCulture) }; (m.GameX, m.GameY) = GameWebMapCoordinateConverter.MysWebToGame(m.X, m.Y); var imageCoordinates = map.ConvertGenshinMapCoordinatesToImageCoordinates(new Point2f((float)m.GameX, (float)m.GameY)); (m.ImageX, m.ImageY) = (imageCoordinates.X, imageCoordinates.Y); return m; }) .ToList(); return new MaskMapPointsResult { Labels = labels, Points = points }; } private Task> GetMihoyoPointListCacheAsync(IReadOnlyList parentLabelIds, CancellationToken ct) { var labelIds = parentLabelIds?.Distinct().OrderBy(x => x).ToArray() ?? Array.Empty(); var provider = GetProvider(); var providerKey = provider == MapPointApiProvider.HoYoLab ? "hoyolab" : "mihoyo-map"; var langSegment = provider == MapPointApiProvider.HoYoLab ? $":lang:{HoYoLabMapApiService.NormalizeLanguage(TaskContext.Instance().Config.MapMaskConfig.HoYoLabLanguage)}" : string.Empty; var key = $"{providerKey}:point-list:2:ys_obc{langSegment}:{string.Join(",", labelIds)}"; var request = new PointListRequest { LabelIds = labelIds.ToList() }; var api = GetMihoyoCompatibleApi(); return _cache.GetOrAddAsync( key, async entry => { entry.AbsoluteExpirationRelativeToNow = CacheDuration; return await api.GetPointListAsync(request, CancellationToken.None); }) .WaitAsync(ct); } private async Task GetMihoyoPointInfoAsync(MaskMapPoint point, CancellationToken ct) { if (!int.TryParse(point.Id, out var pointId)) { return new MaskMapPointInfo { Text = $"点位 ID 非法: {point.Id}" }; } try { var resp = await GetMihoyoCompatibleApi().GetPointInfoAsync(new PointInfoRequest { PointId = pointId }, ct); if (resp.Retcode != 0 || resp.Data == null) { return new MaskMapPointInfo { Text = $"查询失败: {resp.Retcode} {resp.Message}" }; } var content = (resp.Data.Info.Content ?? string.Empty).Trim(); var imageUrl = resp.Data.Info.Img ?? string.Empty; var urlList = resp.Data.Info.UrlList .Where(x => !string.IsNullOrWhiteSpace(x?.Url)) .Select(x => new MaskMapLink { Text = x.Text ?? string.Empty, Url = x.Url ?? string.Empty }) .ToList(); return new MaskMapPointInfo { Text = string.IsNullOrEmpty(content) ? "暂无描述" : content, ImageUrl = imageUrl, UrlList = urlList }; } catch (Exception ex) { _logger.LogDebug(ex, "查询米游社地图点位详情失败"); return new MaskMapPointInfo { Text = "查询失败" }; } } private async Task> GetKongyingLabelCategoriesAsync(CancellationToken ct) { var iconUrlById = await GetKongyingIconUrlByIdCachedAsync(ct); var childrenByCategoryId = await GetKongyingChildrenByCategoryIdCachedAsync(KongyingFirstLayerLabelDefinitions, ct); var categories = new List(KongyingFirstLayerLabelDefinitions.Count); foreach (var def in KongyingFirstLayerLabelDefinitions) { var catIconUrl = iconUrlById.TryGetValue(def.IconId, out var iconUrl) ? iconUrl : string.Empty; var children = childrenByCategoryId.TryGetValue(def.Id, out var list) ? list : Array.Empty(); categories.Add(new MaskMapPointLabel { LabelId = def.Id.ToString(CultureInfo.InvariantCulture), ParentId = "KongyingTavern", Name = def.Name, IconUrl = catIconUrl, PointCount = children.Sum(x => x.PointCount), Children = children }); } return categories; } private Task>> GetKongyingChildrenByCategoryIdCachedAsync( IReadOnlyList<(long Id, long IconId, string Name)> firstLayerLabelDefinitions, CancellationToken ct) { var categoryIds = firstLayerLabelDefinitions .Select(x => x.Id) .Distinct() .OrderBy(x => x) .ToArray(); var key = $"kongying-tavern:children-by-category-id:{string.Join(",", categoryIds)}"; return _cache.GetOrAddAsync>>( key, async entry => { entry.AbsoluteExpirationRelativeToNow = CacheDuration; var items = await GetKongyingItemTypeListCachedAsync(CancellationToken.None); var iconUrlById = await GetKongyingIconUrlByIdCachedAsync(CancellationToken.None); var dict = categoryIds.ToDictionary(x => x, _ => new Dictionary LabelIds, string IconUrl, int PointCount)>(StringComparer.Ordinal)); foreach (var item in items) { if (item.Id == null || item.TypeIdList == null || item.TypeIdList.Count == 0) { continue; } var itemId = item.Id.Value; var itemCount = item.Count ?? 0; var itemIconUrl = (item.IconId != null && iconUrlById.TryGetValue(item.IconId.Value, out var url)) ? url : string.Empty; var nameKey = (item.Name ?? string.Empty).Trim(); foreach (var typeId in item.TypeIdList) { if (!dict.TryGetValue(typeId, out var byName)) { continue; } if (!byName.TryGetValue(nameKey, out var acc)) { acc = (itemId, new List(), itemIconUrl, 0); } acc.LabelIds.Add(itemId); acc.PointCount += itemCount; if (itemId < acc.MinId) { acc.MinId = itemId; } byName[nameKey] = acc; } } var result = dict.ToDictionary( x => x.Key, x => { var parentId = x.Key.ToString(CultureInfo.InvariantCulture); var list = x.Value .Select(kv => { var acc = kv.Value; var ids = acc.LabelIds .Distinct() .OrderBy(id => id) .Select(id => id.ToString(CultureInfo.InvariantCulture)) .ToArray(); return new MaskMapPointLabel { LabelId = acc.MinId.ToString(CultureInfo.InvariantCulture), LabelIds = ids, ParentId = parentId, Name = kv.Key, IconUrl = acc.IconUrl, PointCount = acc.PointCount }; }) .OrderBy(i => i.Name, StringComparer.Ordinal) .ToList(); return (IReadOnlyList)list; }); return result; }) .WaitAsync(ct); } private async Task GetKongyingPointsAsync(IReadOnlyList selectedItems, CancellationToken ct) { if (selectedItems.Count == 0) { return new MaskMapPointsResult(); } IEnumerable GetEffectiveIds(MaskMapPointLabel item) => item.LabelIds is { Count: > 0 } ? item.LabelIds : new[] { item.LabelId }; var selectedItemIdsInOrder = new List(capacity: selectedItems.Count); var selectedItemIds = new HashSet(); foreach (var item in selectedItems) { foreach (var idStr in GetEffectiveIds(item)) { if (!long.TryParse(idStr, out var id)) { continue; } if (selectedItemIds.Add(id)) { selectedItemIdsInOrder.Add(id); } } } var labelsById = new Dictionary(StringComparer.Ordinal); foreach (var item in selectedItems) { foreach (var idStr in GetEffectiveIds(item)) { if (string.IsNullOrWhiteSpace(idStr)) { continue; } if (!labelsById.ContainsKey(idStr)) { labelsById.Add(idStr, new MaskMapPointLabel { LabelId = idStr, Name = item.Name, IconUrl = item.IconUrl }); } } } var labels = labelsById.Values.ToList(); if (selectedItemIds.Count == 0) { return new MaskMapPointsResult { Labels = labels, Points = Array.Empty() }; } var markersByItemId = await GetKongyingMarkersByItemIdCachedAsync(ct); var map = MapManager.GetMap(MapTypes.Teyvat, TaskContext.Instance().Config.PathingConditionConfig.MapMatchingMethod); var markerById = new Dictionary(); foreach (var selectedItemId in selectedItemIdsInOrder) { if (!markersByItemId.TryGetValue(selectedItemId, out var markers)) { continue; } foreach (var marker in markers) { if (marker.Id == null) { continue; } var markerId = marker.Id.Value; if (markerById.ContainsKey(markerId)) { continue; } markerById.Add(markerId, (marker, selectedItemId)); } } var points = new List(capacity: Math.Min(markerById.Count, 4096)); foreach (var kv in markerById) { ct.ThrowIfCancellationRequested(); var markerId = kv.Key; var marker = kv.Value.Marker; var labelItemId = kv.Value.LabelItemId; if (string.IsNullOrWhiteSpace(marker.Position)) { continue; } if (!TryParseKongyingPosition(marker.Position, out var x, out var y)) { continue; } var m = new MaskMapPoint { Id = markerId.ToString(CultureInfo.InvariantCulture), X = x, Y = y, LabelId = labelItemId.ToString(CultureInfo.InvariantCulture), VideoUrls = string.IsNullOrWhiteSpace(marker.VideoPath) ? new List() : new List { new() { Text = string.Empty, Url = marker.VideoPath!.Trim() } } }; (m.GameX, m.GameY) = GameWebMapCoordinateConverter.KongyingTavernToGame(m.X, m.Y); var imageCoordinates = map.ConvertGenshinMapCoordinatesToImageCoordinates(new Point2f((float)m.GameX, (float)m.GameY)); (m.ImageX, m.ImageY) = (imageCoordinates.X, imageCoordinates.Y); points.Add(m); } return new MaskMapPointsResult { Labels = labels, Points = points }; } private async Task GetKongyingPointInfoAsync(MaskMapPoint point, CancellationToken ct) { if (!long.TryParse(point.Id, out var markerId)) { return new MaskMapPointInfo { Text = $"点位 ID 非法: {point.Id}" }; } var markerById = await GetKongyingMarkerByIdCachedAsync(ct); if (!markerById.TryGetValue(markerId, out var marker)) { return new MaskMapPointInfo { Text = "暂无描述" }; } var text = (marker.Content ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(text)) { text = (marker.MarkerTitle ?? string.Empty).Trim(); } return new MaskMapPointInfo { Text = string.IsNullOrWhiteSpace(text) ? "暂无描述" : text, ImageUrl = marker.Picture ?? string.Empty, UrlList = string.IsNullOrWhiteSpace(marker.VideoPath) ? Array.Empty() : new[] { new MaskMapLink { Text = string.Empty, Url = marker.VideoPath!.Trim() } } }; } private Task> GetKongyingItemTypeListCachedAsync(CancellationToken ct) { const string cacheKey = "kongying-tavern:item-types:area-filter-v1"; return _cache.GetOrAddAsync>( cacheKey, async entry => { entry.AbsoluteExpirationRelativeToNow = CacheDuration; var list = await _kongyingTavernApi.GetItemTypeListAsync(CancellationToken.None); return list .Where(x => x.AreaId == null || !KongyingTavernApiService.MaskMapItemTypeExcludedAreaIds.Contains(x.AreaId.Value)) .ToList(); }) .WaitAsync(ct); } private Task> GetKongyingMarkerListCachedAsync(CancellationToken ct) { return _cache.GetOrAddAsync( "kongying-tavern:markers", async entry => { entry.AbsoluteExpirationRelativeToNow = CacheDuration; var list = await _kongyingTavernApi.GetMarkerListAsync(CancellationToken.None); return list; }) .WaitAsync(ct); } private Task> GetKongyingMarkerByIdCachedAsync(CancellationToken ct) { return _cache.GetOrAddAsync>( "kongying-tavern:markers-by-id", async entry => { entry.AbsoluteExpirationRelativeToNow = CacheDuration; var markers = await GetKongyingMarkerListCachedAsync(CancellationToken.None); return markers .Where(x => x.Id != null) .GroupBy(x => x.Id!.Value) .ToDictionary(g => g.Key, g => g.First()); }) .WaitAsync(ct); } private Task>> GetKongyingMarkersByItemIdCachedAsync(CancellationToken ct) { return _cache.GetOrAddAsync>>( "kongying-tavern:markers-by-item-id", async entry => { entry.AbsoluteExpirationRelativeToNow = CacheDuration; var apiStart = Stopwatch.GetTimestamp(); var markers = await GetKongyingMarkerListCachedAsync(CancellationToken.None); var apiElapsed = Stopwatch.GetElapsedTime(apiStart); var responseStart = Stopwatch.GetTimestamp(); var markerListByItemId = new Dictionary>(capacity: 4096); foreach (var marker in markers) { if (marker.Id == null || string.IsNullOrWhiteSpace(marker.Position) || marker.ItemList == null || marker.ItemList.Count == 0) { continue; } var seen = new HashSet(); foreach (var markerItem in marker.ItemList) { if (markerItem.ItemId == null) { continue; } if (!seen.Add(markerItem.ItemId.Value)) { continue; } if (!markerListByItemId.TryGetValue(markerItem.ItemId.Value, out var list)) { list = new List(); markerListByItemId[markerItem.ItemId.Value] = list; } list.Add(marker); } } var result = markerListByItemId.ToDictionary( x => x.Key, x => (IReadOnlyList)x.Value); var responseElapsed = Stopwatch.GetElapsedTime(responseStart); // _logger.LogInformation( // "空荧酒馆 markers-by-item-id: API {ApiMs}ms, 响应处理 {ResponseMs}ms, markers {MarkerCount}, itemIds {ItemIdCount}", // apiElapsed.TotalMilliseconds, // responseElapsed.TotalMilliseconds, // markers.Count, // result.Count); return result; }) .WaitAsync(ct); } private Task> GetKongyingIconUrlByIdCachedAsync(CancellationToken ct) { return _cache.GetOrAddAsync( "kongying-tavern:icons-by-id", async entry => { entry.AbsoluteExpirationRelativeToNow = CacheDuration; var icons = await _kongyingTavernApi.GetIconListAsync(CancellationToken.None); return icons .Where(x => x.Id != null && !string.IsNullOrWhiteSpace(x.Url)) .GroupBy(x => x.Id!.Value) .ToDictionary(g => g.Key, g => g.First().Url!); }) .WaitAsync(ct); } private static bool TryParseKongyingPosition(string position, out double x, out double y) { x = 0; y = 0; if (string.IsNullOrWhiteSpace(position)) { return false; } var parts = position .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length != 2) { return false; } return double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out x) && double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out y); } private static ApiResponse? TryLoadLabelTreeFromLocalExample() { try { var root = AppContext.BaseDirectory; var path = Path.Combine(root, ".trae", "documents", "tree.json"); if (!File.Exists(path)) { return null; } var json = File.ReadAllText(path); return JsonConvert.DeserializeObject>(json); } catch { return null; } } }