diff --git a/apps/backend/Jobs/Magic/MagicAggregateMiscReportsJob.cs b/apps/backend/Jobs/Magic/MagicAggregateMiscReportsJob.cs index 5f0694ae5..584cd83e6 100644 --- a/apps/backend/Jobs/Magic/MagicAggregateMiscReportsJob.cs +++ b/apps/backend/Jobs/Magic/MagicAggregateMiscReportsJob.cs @@ -32,7 +32,8 @@ public override async Task Run(string[] data) var dataCounts = new Dictionary(); var reportQuery = Context.MiscReport .AsNoTracking() - .Where(mr => mr.Region == (short)region && + .Where(mr => mr.ReportType == reportType && + mr.Region == (short)region && mr.ExpiresAt > DateTime.UtcNow) .AsAsyncEnumerable(); await foreach (var report in reportQuery) diff --git a/apps/backend/Jobs/User/UserUploadJob.cs b/apps/backend/Jobs/User/UserUploadJob.cs index 20266d731..187dd29de 100644 --- a/apps/backend/Jobs/User/UserUploadJob.cs +++ b/apps/backend/Jobs/User/UserUploadJob.cs @@ -34,6 +34,13 @@ public class UserUploadJob : JobBase private static readonly Regex PlayerGuidRegex = new(@"^Player-\d+-([0-9A-Fa-f]+)$", RegexOptions.Compiled); private static readonly Regex ValidGuildRegex = new(@"^\d+/", RegexOptions.Compiled); + private static readonly Dictionary ExpansionToMiscReportType = new() + { + { 6, MiscReportType.LegionEmissaries }, + { 7, MiscReportType.BattleForAzerothEmissaries }, + { 8, MiscReportType.ShadowlandsCallings }, + }; + private static readonly AsyncRetryPolicy LockRetryPolicy = Policy .HandleResult(r => r == false) .WaitAndRetryAsync( @@ -1080,69 +1087,123 @@ await localContext.PlayerWarbankItem private async Task HandleMiscReports(WowRegion accountRegion, Upload parsedData) { - if (parsedData.Delves == null) - { - return; - } - await using var localContext = await ContextFactory.CreateDbContextAsync(); var reportMap = await localContext.MiscReport .Where(mr => mr.UserId == _userId && mr.Region == (short)accountRegion) .ToDictionaryAsync(mr => mr.ReportType); + var now = DateTime.UtcNow; long currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - DateTime? expiresAt = null; - List<(int, string)> delves = []; - foreach (string delveString in parsedData.Delves) + var reports = new List<(MiscReportType ReportType, DateTime ExpiresAt, string Data)>(); + + // Delves + if (parsedData.Delves != null) { - string[] parts = delveString.Split(":"); - if (parts.Length != 3) + DateTime? expiresAt = null; + List<(int, string)> delves = []; + foreach (string delveString in parsedData.Delves) { - continue; + string[] parts = delveString.Split(":"); + if (parts.Length != 3) + { + continue; + } + + // unixTimestamp:poiId:story + if (!int.TryParse(parts[0], out int validUntil) || + !int.TryParse(parts[1], out int poiId) || + string.IsNullOrWhiteSpace(parts[2]) || + validUntil < currentTimestamp || + poiId == 0) + { + // log + continue; + } + + expiresAt ??= validUntil.AsUtcDateTime(); + + delves.Add((poiId, parts[2])); } - // unixTimestamp:poiId:story - int.TryParse(parts[0], out int validUntil); - int.TryParse(parts[1], out int poiId); - if (validUntil < currentTimestamp || poiId == 0 || string.IsNullOrWhiteSpace(parts[2])) + if (expiresAt != null && delves.Count > 0) { - // log - continue; - } + List dataParts = []; + foreach ((int poiId, string story) in delves.OrderBy(tup => tup.Item1)) + { + dataParts.Add([poiId, story]); + } - expiresAt ??= validUntil.AsUtcDateTime(); + string data = JsonSerializer.Serialize(dataParts); - delves.Add((poiId, parts[2])); + reports.Add((MiscReportType.Delve, expiresAt.Value, data)); + } } - if (expiresAt != null && delves.Count > 0) + // Emissary Quests + if (parsedData.EmissaryQuests != null) { - List dataParts = []; - foreach ((int poiId, string story) in delves.OrderBy(tup => tup.Item1)) + foreach ((int expansion, var emissaryQuestStrings) in parsedData.EmissaryQuests) { - dataParts.Add([poiId, story]); - } + if (!ExpansionToMiscReportType.TryGetValue(expansion, out var reportType)) + { + // log + continue; + } + + List<(int QuestId, int ExpiresAt)> emissaryQuests = new(); + foreach (string emissaryQuestString in emissaryQuestStrings) + { + string[] parts = emissaryQuestString.Split(":"); + if (parts.Length != 2) + { + continue; + } - string data = JsonSerializer.Serialize(dataParts); + // questId:expires + if (!int.TryParse(parts[0], out int questId) || + !int.TryParse(parts[1], out int validUntil) || + questId == 0 || + validUntil < currentTimestamp) + { + // log + continue; + } - if (reportMap.TryGetValue(MiscReportType.Delve, out var dbReport)) - { - dbReport.Data = data; + emissaryQuests.Add((questId, validUntil)); + } + + if (emissaryQuests.Count > 0) + { + List dataParts = []; + foreach ((int questId, int expiresAt) in emissaryQuests.OrderBy(tup => tup.ExpiresAt)) + { + dataParts.Add([questId, expiresAt]); + } + + string data = JsonSerializer.Serialize(dataParts); + + reports.Add((reportType, emissaryQuests.Last().ExpiresAt.AsUtcDateTime(), data)); + } } - else + } + + foreach (var report in reports) + { + if (!reportMap.TryGetValue(report.ReportType, out var dbReport)) { dbReport = new MiscReport { UserId = _userId, - ExpiresAt = expiresAt.Value, - ReportedAt = DateTime.UtcNow, - ReportType = MiscReportType.Delve, + ReportedAt = now, + ReportType = report.ReportType, Region = (short)accountRegion, - Data = data, }; localContext.MiscReport.Add(dbReport); } + + dbReport.Data = report.Data; + dbReport.ExpiresAt = report.ExpiresAt; } await localContext.SaveChangesAsync(); diff --git a/apps/backend/Models/Uploads/Upload.cs b/apps/backend/Models/Uploads/Upload.cs index 35b68375d..5d482e522 100644 --- a/apps/backend/Models/Uploads/Upload.cs +++ b/apps/backend/Models/Uploads/Upload.cs @@ -21,6 +21,7 @@ public class Upload public Dictionary Decor { get; set; } public List Delves { get; set; } + public Dictionary> EmissaryQuests { get; set; } public List Illusions { get; set; } public Dictionary Paragons { get; set; } public List Quests { get; set; } diff --git a/apps/backend/Program.cs b/apps/backend/Program.cs index d4aeb88df..2536bc13a 100644 --- a/apps/backend/Program.cs +++ b/apps/backend/Program.cs @@ -30,8 +30,6 @@ public class Program public static void Main(string[] args) { - ServicePointManager.DefaultConnectionLimit = 10; - Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(Configuration) .Enrich.FromLogContext() @@ -135,6 +133,10 @@ private static void ConfigureServices(HostBuilderContext hostContext, IServiceCo { client.Timeout = TimeSpan.FromSeconds(120); }) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + MaxConnectionsPerServer = 10, + }) .AddPolicyHandler(rateLimitPolicy) .SetHandlerLifetime(Timeout.InfiniteTimeSpan); diff --git a/apps/frontend/components/home/table/head/HomeTableHeadTasks.svelte b/apps/frontend/components/home/table/head/HomeTableHeadTasks.svelte index 868642f5e..7b2771517 100644 --- a/apps/frontend/components/home/table/head/HomeTableHeadTasks.svelte +++ b/apps/frontend/components/home/table/head/HomeTableHeadTasks.svelte @@ -41,12 +41,15 @@ {@const [taskName, choreName] = fullTaskName.split('|', 2)} {@const task = taskMap[taskName] || settingsState.customTaskMap[fullTaskName]} {@const chore = task.chores.find((task) => task?.key === choreName)} - {@const customExpiry = chore?.decorationFunc?.( - chore?.customExpiryFunc(userState.general.characters[0], timeState.slowTime) + {@const customExpiry = chore?.customExpiryFunc?.( + chore, + userState.general.characters[0], + timeState.slowTime )} + {@const decoratorClass = chore?.decorationFunc?.(customExpiry)} setSortState(fullTaskName)} @@ -54,6 +57,7 @@ component: Tooltip, props: { chore, + expiresAt: customExpiry, fullTaskName, task, }, diff --git a/apps/frontend/components/tooltips/task/TooltipTaskHead.svelte b/apps/frontend/components/tooltips/task/TooltipTaskHead.svelte index e1d7da96d..e76a7a8c4 100644 --- a/apps/frontend/components/tooltips/task/TooltipTaskHead.svelte +++ b/apps/frontend/components/tooltips/task/TooltipTaskHead.svelte @@ -1,7 +1,11 @@ - - {#if quality > 0} { + const reputation = Reputation[factionEnumString as keyof typeof Reputation]; + return ``; + }); + // {reputation:amount|factionId} html = html.replaceAll( /\{reputation:(\d+)\|(\d+)\}/g, diff --git a/apps/frontend/stores/lazy/character.ts b/apps/frontend/stores/lazy/character.ts index 555f419d2..7b8cc8ede 100644 --- a/apps/frontend/stores/lazy/character.ts +++ b/apps/frontend/stores/lazy/character.ts @@ -877,7 +877,7 @@ function getExpiry(choreTask: Chore, character: Character, charScanned: DateTime if (choreTask.questReset === DbResetType.Weekly) { expiresAt = getNextWeeklyResetFromTime(charScanned, character.realm?.region || Region.US); } else if (choreTask.questReset === DbResetType.Custom) { - expiresAt = choreTask.customExpiryFunc(character, charScanned); + expiresAt = choreTask.customExpiryFunc(choreTask, character, charScanned); } else { expiresAt = getNextDailyResetFromTime(charScanned, character.realm?.region || Region.US); } diff --git a/apps/frontend/types/reputation-data.ts b/apps/frontend/types/reputation-data.ts new file mode 100644 index 000000000..e41510427 --- /dev/null +++ b/apps/frontend/types/reputation-data.ts @@ -0,0 +1,7 @@ +import type { Holiday } from '@/enums/holiday'; + +export type ReputationData = { + emissaryHoliday?: Holiday; + emissaryQuestId?: number; + icon: string; +}; diff --git a/apps/frontend/types/tasks.ts b/apps/frontend/types/tasks.ts index f1d408cfe..8d90a0465 100644 --- a/apps/frontend/types/tasks.ts +++ b/apps/frontend/types/tasks.ts @@ -28,6 +28,7 @@ export type Chore = { maximumLevel?: number; minimumLevel?: number; noAlone?: boolean; + noTooltipIcon?: boolean; overrideNeed?: number; questCount?: number; questIds?: number[] | ((char: Character, chore?: Chore) => number[]); @@ -54,7 +55,12 @@ export type Chore = { /** * Function to generate a custom expiry time */ - customExpiryFunc?: (char: Character, scannedAt: DateTime, questIds?: number[]) => DateTime; + customExpiryFunc?: ( + chore: Chore, + char: Character, + scannedAt: DateTime, + questIds?: number[] + ) => DateTime; /** * Function to ???? */ diff --git a/apps/frontend/user-home/state/activeHolidays.svelte.ts b/apps/frontend/user-home/state/activeHolidays.svelte.ts index 2fa641e73..2fb60b239 100644 --- a/apps/frontend/user-home/state/activeHolidays.svelte.ts +++ b/apps/frontend/user-home/state/activeHolidays.svelte.ts @@ -1,11 +1,13 @@ -import type { DateTime } from 'luxon'; +import { DateTime } from 'luxon'; +import { holidayIds } from '@/data/holidays'; +import { emissaryQuestToHoliday } from '@/data/reputation'; import { timeState } from '@/shared/state/time.svelte'; import { wowthingData } from '@/shared/stores/data'; import { userState } from '@/user-home/state/user'; -import type { StaticDataHoliday } from '@/shared/stores/static/types'; -import { holidayIds } from '@/data/holidays'; +import { dynamicDataStore } from '@/user-home/stores/dynamicData'; import { getNumberKeyedEntries } from '@/utils/get-number-keyed-entries'; +import type { StaticDataHoliday } from '@/shared/stores/static/types'; export type ActiveHoliday = { holiday: StaticDataHoliday; @@ -110,6 +112,27 @@ class ActiveHolidays { } } + // fake holidays for emissary quests + for (const region of allRegions) { + const dynamicData = dynamicDataStore.getCached(region); + for (const emissaryQuests of Object.values(dynamicData.emissaryQuests)) { + const validEmissaryQuests = emissaryQuests.filter( + (eq) => eq.expiresAt > currentTime + ); + for (const emissaryQuest of validEmissaryQuests) { + const emissaryHoliday = emissaryQuestToHoliday[emissaryQuest.questId]; + if (emissaryHoliday) { + activeHolidays[emissaryHoliday] = { + holiday: null, + startDate: emissaryQuest.expiresAt.minus({ days: 1 }), + endDate: emissaryQuest.expiresAt, + soon: false, + }; + } + } + } + } + this.cachedActive[regionMask] = activeHolidays; this.cachedTime[regionMask] = currentTime; diff --git a/apps/frontend/user-home/state/user/derived.svelte.ts b/apps/frontend/user-home/state/user/derived.svelte.ts index 0b3bb50fc..9709671b3 100644 --- a/apps/frontend/user-home/state/user/derived.svelte.ts +++ b/apps/frontend/user-home/state/user/derived.svelte.ts @@ -583,7 +583,7 @@ export class DataUserDerived { : chore.questIds; if (choreReset === DbResetType.Custom) { - expiresAt = chore.customExpiryFunc(character, charScanned, questIds); + expiresAt = chore.customExpiryFunc(chore, character, charScanned, questIds); } for (const questId of questIds) { diff --git a/apps/frontend/user-home/stores/dynamicData.ts b/apps/frontend/user-home/stores/dynamicData.ts index f3d580faf..e6f0cdf99 100644 --- a/apps/frontend/user-home/stores/dynamicData.ts +++ b/apps/frontend/user-home/stores/dynamicData.ts @@ -3,13 +3,14 @@ import sum from 'lodash/sum'; import { DateTime } from 'luxon'; import { getNumberKeyedEntries } from '@/utils/get-number-keyed-entries'; -import type { ApiWorldQuest, ApiWorldQuestReward } from '@/types/world-quests'; import type { RewardType } from '@/enums/reward-type'; +import type { ApiWorldQuest, ApiWorldQuestReward } from '@/types/world-quests'; interface ApiDynamicData { region: number; delves: [number, string][]; - worldQuests: Record; + emissaryQuests: Record; + worldQuests: Record; } interface ApiWorldQuestJson { @@ -21,6 +22,7 @@ interface ApiWorldQuestJson { class DynamicData { public delves: { poiId: number; story: string }[] = []; + public emissaryQuests: Record = {}; public worldQuests: Record = {}; } @@ -51,6 +53,21 @@ class DynamicDataStore { finalData.delves.push({ poiId, story }); } + // EmissaryQuests + for (const [expansion, emissaryQuests] of getNumberKeyedEntries( + responseData.emissaryQuests + )) { + finalData.emissaryQuests[expansion] = []; + const expansionQuests = finalData.emissaryQuests[expansion]; + + for (const [questId, expiresAt] of emissaryQuests) { + expansionQuests.push({ + questId, + expiresAt: DateTime.fromSeconds(expiresAt), + }); + } + } + // World Quests for (const [zoneId, apiWorldQuests] of getNumberKeyedEntries( responseData.worldQuests diff --git a/apps/web/Models/Api/ApiDynamicData.cs b/apps/web/Models/Api/ApiDynamicData.cs index 8970bad1b..c15e80837 100644 --- a/apps/web/Models/Api/ApiDynamicData.cs +++ b/apps/web/Models/Api/ApiDynamicData.cs @@ -7,5 +7,6 @@ public class ApiDynamicData { public WowRegion Region { get; set; } public object[] Delves { get; set; } + public Dictionary EmissaryQuests { get; set; } public Dictionary WorldQuests { get; set; } } diff --git a/apps/web/Services/MemoryCacheService.cs b/apps/web/Services/MemoryCacheService.cs index 8bd32cb4d..259770e35 100644 --- a/apps/web/Services/MemoryCacheService.cs +++ b/apps/web/Services/MemoryCacheService.cs @@ -22,6 +22,13 @@ public class MemoryCacheService private readonly UserManager _userManager; private readonly WowDbContext _context; + private static readonly (int Expansion, MiscReportType ReportType)[] Emissaries = + [ + (6, MiscReportType.LegionEmissaries), + (7, MiscReportType.BattleForAzerothEmissaries), + (8, MiscReportType.ShadowlandsCallings), + ]; + public MemoryCacheService( CacheService cacheService, IConnectionMultiplexer redis, @@ -165,10 +172,20 @@ public async Task GetDynamicDataForRegion(short region) ? JsonSerializer.Deserialize(delveAggregate.JsonData ?? "[]") : []; + var emissaryQuests = new Dictionary(); + foreach (var emissary in Emissaries) + { + var emissaryAggregate = miscAggregates.FirstOrDefault(ma => ma.ReportType == emissary.ReportType); + emissaryQuests[emissary.Expansion] = emissaryAggregate != null + ? JsonSerializer.Deserialize(emissaryAggregate.JsonData ?? "[]") + : []; + } + return new ApiDynamicData { Region = (WowRegion)region, Delves = delveJson, + EmissaryQuests = emissaryQuests, WorldQuests = worldQuestAggregates.ToGroupedDictionary(wqa => wqa.ZoneId), }; } diff --git a/packages/csharp-lib/Enums/MiscReportType.cs b/packages/csharp-lib/Enums/MiscReportType.cs index c50fb13a8..2a50839f7 100644 --- a/packages/csharp-lib/Enums/MiscReportType.cs +++ b/packages/csharp-lib/Enums/MiscReportType.cs @@ -3,4 +3,8 @@ namespace Wowthing.Lib.Enums; public enum MiscReportType : short { Delve = 1, + LegionEmissaries = 2, + BattleForAzerothEmissaries = 3, + ShadowlandsCallings = 4, } +