Files
better-genshin-impact/BetterGenshinImpact/Service/MaskMapPointService.cs

753 lines
28 KiB
C#

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<MaskMapPointService> _logger;
private readonly IAppCache _cache;
private readonly IHoYoLabMapApiService _hoyolabMapApi;
private readonly IMihoyoMapApiService _mihoyoMapApi;
private readonly IKongyingTavernApiService _kongyingTavernApi;
public MaskMapPointService(
ILogger<MaskMapPointService> logger,
IAppCache cache,
IHoYoLabMapApiService hoyolabMapApi,
IMihoyoMapApiService mihoyoMapApi,
IKongyingTavernApiService kongyingTavernApi)
{
_logger = logger;
_cache = cache;
_hoyolabMapApi = hoyolabMapApi;
_mihoyoMapApi = mihoyoMapApi;
_kongyingTavernApi = kongyingTavernApi;
}
public Task<IReadOnlyList<MaskMapPointLabel>> GetLabelCategoriesAsync(CancellationToken ct = default)
{
return GetProvider() switch
{
MapPointApiProvider.KongyingTavern => GetKongyingLabelCategoriesAsync(ct),
_ => GetMihoyoLabelCategoriesAsync(ct)
};
}
public Task<MaskMapPointsResult> GetPointsAsync(IReadOnlyList<MaskMapPointLabel> selectedItems, CancellationToken ct = default)
{
return GetProvider() switch
{
MapPointApiProvider.KongyingTavern => GetKongyingPointsAsync(selectedItems, ct),
_ => GetMihoyoPointsAsync(selectedItems, ct)
};
}
public Task<MaskMapPointInfo> 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<IReadOnlyList<MaskMapPointLabel>> GetMihoyoLabelCategoriesAsync(CancellationToken ct)
{
ApiResponse<LabelTreeData>? 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<MaskMapPointLabel>();
}
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<LabelNode> { 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<MaskMapPointsResult> GetMihoyoPointsAsync(IReadOnlyList<MaskMapPointLabel> 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<MaskMapPoint>()
};
}
ApiResponse<PointListData> resp;
try
{
resp = await GetMihoyoPointListCacheAsync(parentLabelIds, ct);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "调用米游社地图接口获取点位列表失败");
return new MaskMapPointsResult { Labels = labels, Points = Array.Empty<MaskMapPoint>() };
}
if (resp.Retcode != 0 || resp.Data == null)
{
_logger.LogWarning("获取地图点位列表失败: {Retcode} {Message}", resp.Retcode, resp.Message);
return new MaskMapPointsResult { Labels = labels, Points = Array.Empty<MaskMapPoint>() };
}
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<ApiResponse<PointListData>> GetMihoyoPointListCacheAsync(IReadOnlyList<int> parentLabelIds, CancellationToken ct)
{
var labelIds = parentLabelIds?.Distinct().OrderBy(x => x).ToArray() ?? Array.Empty<int>();
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<MaskMapPointInfo> 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<IReadOnlyList<MaskMapPointLabel>> GetKongyingLabelCategoriesAsync(CancellationToken ct)
{
var iconUrlById = await GetKongyingIconUrlByIdCachedAsync(ct);
var childrenByCategoryId = await GetKongyingChildrenByCategoryIdCachedAsync(KongyingFirstLayerLabelDefinitions, ct);
var categories = new List<MaskMapPointLabel>(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<MaskMapPointLabel>();
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<IReadOnlyDictionary<long, IReadOnlyList<MaskMapPointLabel>>> 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<IReadOnlyDictionary<long, IReadOnlyList<MaskMapPointLabel>>>(
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<string, (long MinId, List<long> 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<long>(), 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<MaskMapPointLabel>)list;
});
return result;
})
.WaitAsync(ct);
}
private async Task<MaskMapPointsResult> GetKongyingPointsAsync(IReadOnlyList<MaskMapPointLabel> selectedItems, CancellationToken ct)
{
if (selectedItems.Count == 0)
{
return new MaskMapPointsResult();
}
IEnumerable<string> GetEffectiveIds(MaskMapPointLabel item) =>
item.LabelIds is { Count: > 0 } ? item.LabelIds : new[] { item.LabelId };
var selectedItemIdsInOrder = new List<long>(capacity: selectedItems.Count);
var selectedItemIds = new HashSet<long>();
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<string, MaskMapPointLabel>(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<MaskMapPoint>() };
}
var markersByItemId = await GetKongyingMarkersByItemIdCachedAsync(ct);
var map = MapManager.GetMap(MapTypes.Teyvat, TaskContext.Instance().Config.PathingConditionConfig.MapMatchingMethod);
var markerById = new Dictionary<long, (MarkerVo Marker, long LabelItemId)>();
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<MaskMapPoint>(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<MaskMapLink>()
: new List<MaskMapLink>
{
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<MaskMapPointInfo> 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<MaskMapLink>()
: new[]
{
new MaskMapLink
{
Text = string.Empty,
Url = marker.VideoPath!.Trim()
}
}
};
}
private Task<IReadOnlyList<ItemTypeVo>> GetKongyingItemTypeListCachedAsync(CancellationToken ct)
{
const string cacheKey = "kongying-tavern:item-types:area-filter-v1";
return _cache.GetOrAddAsync<IReadOnlyList<ItemTypeVo>>(
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<IReadOnlyList<MarkerVo>> 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<IReadOnlyDictionary<long, MarkerVo>> GetKongyingMarkerByIdCachedAsync(CancellationToken ct)
{
return _cache.GetOrAddAsync<IReadOnlyDictionary<long, MarkerVo>>(
"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<IReadOnlyDictionary<long, IReadOnlyList<MarkerVo>>> GetKongyingMarkersByItemIdCachedAsync(CancellationToken ct)
{
return _cache.GetOrAddAsync<IReadOnlyDictionary<long, IReadOnlyList<MarkerVo>>>(
"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<long, List<MarkerVo>>(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<long>();
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<MarkerVo>();
markerListByItemId[markerItem.ItemId.Value] = list;
}
list.Add(marker);
}
}
var result = markerListByItemId.ToDictionary(
x => x.Key,
x => (IReadOnlyList<MarkerVo>)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<Dictionary<long, string>> 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<LabelTreeData>? 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<ApiResponse<LabelTreeData>>(json);
}
catch
{
return null;
}
}
}