diff --git a/Questionable/Data/QuestData.cs b/Questionable/Data/QuestData.cs index b1f26524..5d7b7022 100644 --- a/Questionable/Data/QuestData.cs +++ b/Questionable/Data/QuestData.cs @@ -235,6 +235,16 @@ internal sealed class QuestData .ToList(); } + public List GetAllByAlliedSociety(EAlliedSociety alliedSociety) + { + return _quests.Values + .Where(x => x is QuestInfo) + .Cast() + .Where(x => x.AlliedSociety == alliedSociety) + .OrderBy(x => x.QuestId) + .ToList(); + } + public List GetClassJobQuests(EClassJob classJob) { List chapterIds = classJob switch diff --git a/Questionable/Functions/AlliedSocietyQuestFunctions.cs b/Questionable/Functions/AlliedSocietyQuestFunctions.cs new file mode 100644 index 00000000..4fd70419 --- /dev/null +++ b/Questionable/Functions/AlliedSocietyQuestFunctions.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FFXIVClientStructs.FFXIV.Client.Game; +using Microsoft.Extensions.Logging; +using Questionable.Data; +using Questionable.Model; +using Questionable.Model.Questing; + +namespace Questionable.Functions; + +internal sealed class AlliedSocietyQuestFunctions +{ + private readonly ILogger _logger; + private readonly Dictionary> _questsByAlliedSociety = []; + private readonly Dictionary<(uint NpcDataId, byte Seed, bool OutranksAll), List> _dailyQuests = []; + + public AlliedSocietyQuestFunctions(QuestData questData, ILogger logger) + { + _logger = logger; + foreach (var alliedSociety in Enum.GetValues().Where(x => x != EAlliedSociety.None)) + { + var allQuests = questData.GetAllByAlliedSociety(alliedSociety); + var questsByIssuer = allQuests + .Where(x => x.IsRepeatable) + .GroupBy(x => x.IssuerDataId) + .ToDictionary(x => x.Key, + x => x.OrderBy(y => y.AlliedSocietyQuestGroup == 3).ThenBy(y => y.QuestId).ToList()); + foreach ((uint issuerDataId, List quests) in questsByIssuer) + { + var npcData = new NpcData { IssuerDataId = issuerDataId, AllQuests = quests }; + if (_questsByAlliedSociety.TryGetValue(alliedSociety, out List? existingNpcs)) + existingNpcs.Add(npcData); + else + _questsByAlliedSociety[alliedSociety] = [npcData]; + } + } + } + + public unsafe List GetAvailableAlliedSocietyQuests(EAlliedSociety alliedSociety) + { + byte rankData = QuestManager.Instance()->BeastReputation[(int)alliedSociety - 1].Rank; + byte currentRank = (byte)(rankData & 0x7F); + if (currentRank == 0) + return []; + + bool rankedUp = (rankData & 0x80) != 0; + byte seed = 183; + List result = []; + foreach (NpcData npcData in _questsByAlliedSociety[alliedSociety]) + { + bool outranksAll = npcData.AllQuests.All(x => currentRank > x.AlliedSocietyRank); + var key = (NpcDataId: npcData.IssuerDataId, seed, outranksAll); + if (_dailyQuests.TryGetValue(key, out List? questIds)) + result.AddRange(questIds); + else + { + var quests = CalculateAvailableQuests(npcData.AllQuests, seed, outranksAll, currentRank, rankedUp); + _logger.LogInformation("Available for {Tribe} (Issuer: {IssuerId}: {Quests}", alliedSociety, npcData.IssuerDataId, string.Join(", ", quests)); + + _dailyQuests[key] = quests; + result.AddRange(quests); + } + } + + return result; + } + + private static List CalculateAvailableQuests(List allQuests, byte seed, bool outranksAll, + byte currentRank, bool rankedUp) + { + List eligible = [.. allQuests.Where(q => IsEligible(q, currentRank, rankedUp))]; + List available = []; + if (eligible.Count == 0) + return []; + + var rng = new Rng(seed); + if (outranksAll) + { + for (int i = 0, cnt = Math.Min(eligible.Count, 3); i < cnt; ++i) + { + var index = rng.Next(eligible.Count); + while (available.Contains(eligible[index])) + index = (index + 1) % eligible.Count; + available.Add(eligible[index]); + } + } + else + { + var firstExclusive = eligible.FindIndex(q => q.AlliedSocietyQuestGroup == 3); + if (firstExclusive >= 0) + available.Add(eligible[firstExclusive + rng.Next(eligible.Count - firstExclusive)]); + else + firstExclusive = eligible.Count; + for (int i = available.Count, cnt = Math.Min(firstExclusive, 3); i < cnt; ++i) + { + var index = rng.Next(firstExclusive); + while (available.Contains(eligible[index])) + index = (index + 1) % firstExclusive; + available.Add(eligible[index]); + } + } + + return available.Select(x => (QuestId)x.QuestId).ToList(); + } + + private static bool IsEligible(QuestInfo questInfo, byte currentRank, bool rankedUp) + { + return rankedUp ? questInfo.AlliedSocietyRank == currentRank : questInfo.AlliedSocietyRank <= currentRank; + } + + private sealed class NpcData + { + public required uint IssuerDataId { get; init; } + public required List AllQuests { get; init; } = []; + } + + private record struct Rng(uint S0, uint S1 = 0, uint S2 = 0, uint S3 = 0) + { + public int Next(int range) + { + (S0, S1, S2, S3) = (S3, Transform(S0, S1), S1, S2); + return (int)(S1 % range); + } + + // returns new value for s1 + private static uint Transform(uint s0, uint s1) + { + var temp = s0 ^ (s0 << 11); + return s1 ^ temp ^ ((temp ^ (s1 >> 11)) >> 8); + } + } +} diff --git a/Questionable/Functions/QuestFunctions.cs b/Questionable/Functions/QuestFunctions.cs index 2f752628..2f0cb841 100644 --- a/Questionable/Functions/QuestFunctions.cs +++ b/Questionable/Functions/QuestFunctions.cs @@ -28,6 +28,7 @@ internal sealed unsafe class QuestFunctions private readonly QuestRegistry _questRegistry; private readonly QuestData _questData; private readonly AetheryteFunctions _aetheryteFunctions; + private readonly AlliedSocietyQuestFunctions _alliedSocietyQuestFunctions; private readonly AlliedSocietyData _alliedSocietyData; private readonly Configuration _configuration; private readonly IDataManager _dataManager; @@ -38,6 +39,7 @@ internal sealed unsafe class QuestFunctions QuestRegistry questRegistry, QuestData questData, AetheryteFunctions aetheryteFunctions, + AlliedSocietyQuestFunctions alliedSocietyQuestFunctions, AlliedSocietyData alliedSocietyData, Configuration configuration, IDataManager dataManager, @@ -47,6 +49,7 @@ internal sealed unsafe class QuestFunctions _questRegistry = questRegistry; _questData = questData; _aetheryteFunctions = aetheryteFunctions; + _alliedSocietyQuestFunctions = alliedSocietyQuestFunctions; _alliedSocietyData = alliedSocietyData; _configuration = configuration; _dataManager = dataManager; @@ -447,8 +450,11 @@ internal sealed unsafe class QuestFunctions if (IsQuestAccepted(questId)) return false; - if (QuestManager.Instance()->IsDailyQuestCompleted(questId.Value)) - return false; + if (quest.Info.AlliedSociety != EAlliedSociety.None) + { + if (QuestManager.Instance()->IsDailyQuestCompleted(questId.Value)) + return false; + } } else { @@ -546,6 +552,9 @@ internal sealed unsafe class QuestFunctions if (questInfo.GrandCompany != GrandCompany.None && questInfo.GrandCompany != GetGrandCompany()) return true; + if (questInfo.AlliedSociety != EAlliedSociety.None) + return !IsDailyAlliedSocietyQuestAndAvailableToday(questId); + return !HasCompletedPreviousQuests(questInfo, extraCompletedQuest) || !HasCompletedPreviousInstances(questInfo); } @@ -569,6 +578,21 @@ internal sealed unsafe class QuestFunctions return !HasCompletedPreviousQuests(questInfo, null); } + public bool IsDailyAlliedSocietyQuest(QuestId questId) + { + var questInfo = (QuestInfo)_questData.GetQuestInfo(questId); + return questInfo.AlliedSociety != EAlliedSociety.None && questInfo.IsRepeatable; + } + + public bool IsDailyAlliedSocietyQuestAndAvailableToday(QuestId questId) + { + if (!IsDailyAlliedSocietyQuest(questId)) + return false; + + var questInfo = (QuestInfo)_questData.GetQuestInfo(questId); + return _alliedSocietyQuestFunctions.GetAvailableAlliedSocietyQuests(questInfo.AlliedSociety).Contains(questId); + } + public bool IsQuestUnobtainable(ElementId elementId, ElementId? extraCompletedQuest = null) { if (elementId is QuestId questId) diff --git a/Questionable/Model/QuestInfo.cs b/Questionable/Model/QuestInfo.cs index bc3d1838..32f80ff4 100644 --- a/Questionable/Model/QuestInfo.cs +++ b/Questionable/Model/QuestInfo.cs @@ -60,6 +60,8 @@ internal sealed class QuestInfo : IQuestInfo PreviousInstanceContentJoin = (EQuestJoin)quest.InstanceContentJoin; GrandCompany = (GrandCompany)quest.GrandCompany.RowId; AlliedSociety = (EAlliedSociety)quest.BeastTribe.RowId; + AlliedSocietyQuestGroup = quest.Unknown11; + AlliedSocietyRank = (int)quest.BeastReputationRank.RowId; ClassJobs = QuestInfoUtils.AsList(quest.ClassJobCategory0.ValueNullable!); IsSeasonalEvent = quest.Festival.RowId != 0; NewGamePlusChapter = newGamePlusChapter; @@ -85,6 +87,8 @@ internal sealed class QuestInfo : IQuestInfo public bool CompletesInstantly { get; } public GrandCompany GrandCompany { get; } public EAlliedSociety AlliedSociety { get; } + public byte AlliedSocietyQuestGroup { get; } + public int AlliedSocietyRank { get; } public IReadOnlyList ClassJobs { get; } public bool IsSeasonalEvent { get; } public uint NewGamePlusChapter { get; } diff --git a/Questionable/QuestionablePlugin.cs b/Questionable/QuestionablePlugin.cs index a4eabbaa..2af5c204 100644 --- a/Questionable/QuestionablePlugin.cs +++ b/Questionable/QuestionablePlugin.cs @@ -111,6 +111,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Questionable/Windows/UiUtils.cs b/Questionable/Windows/UiUtils.cs index 270a76d9..9c51f253 100644 --- a/Questionable/Windows/UiUtils.cs +++ b/Questionable/Windows/UiUtils.cs @@ -24,6 +24,15 @@ internal sealed class UiUtils { if (_questFunctions.IsQuestAccepted(elementId)) return (ImGuiColors.DalamudYellow, FontAwesomeIcon.PersonWalkingArrowRight, "Active"); + else if (elementId is QuestId questId && _questFunctions.IsDailyAlliedSocietyQuestAndAvailableToday(questId)) + { + if (!_questFunctions.IsReadyToAcceptQuest(questId)) + return (ImGuiColors.ParsedGreen, FontAwesomeIcon.Check, "Complete"); + else if (_questFunctions.IsQuestComplete(questId)) + return (ImGuiColors.ParsedBlue, FontAwesomeIcon.Running, "Available (Complete)"); + else + return (ImGuiColors.DalamudYellow, FontAwesomeIcon.Running, "Available"); + } else if (_questFunctions.IsQuestAcceptedOrComplete(elementId)) return (ImGuiColors.ParsedGreen, FontAwesomeIcon.Check, "Complete"); else if (_questFunctions.IsQuestUnobtainable(elementId))