Show available daily quests in journal
authorLiza Carvelli <liza@carvel.li>
Sun, 8 Dec 2024 17:13:22 +0000 (18:13 +0100)
committerLiza Carvelli <liza@carvel.li>
Sun, 8 Dec 2024 17:13:22 +0000 (18:13 +0100)
Questionable/Data/QuestData.cs
Questionable/Functions/AlliedSocietyQuestFunctions.cs [new file with mode: 0644]
Questionable/Functions/QuestFunctions.cs
Questionable/Model/QuestInfo.cs
Questionable/QuestionablePlugin.cs
Questionable/Windows/UiUtils.cs

index b1f26524b162f7936f03c5822e54d83186921d6c..5d7b7022975d5924783b2e245245ca08e84dab02 100644 (file)
@@ -235,6 +235,16 @@ internal sealed class QuestData
             .ToList();
     }
 
+    public List<QuestInfo> GetAllByAlliedSociety(EAlliedSociety alliedSociety)
+    {
+        return _quests.Values
+            .Where(x => x is QuestInfo)
+            .Cast<QuestInfo>()
+            .Where(x => x.AlliedSociety == alliedSociety)
+            .OrderBy(x => x.QuestId)
+            .ToList();
+    }
+
     public List<QuestInfo> GetClassJobQuests(EClassJob classJob)
     {
         List<uint> chapterIds = classJob switch
diff --git a/Questionable/Functions/AlliedSocietyQuestFunctions.cs b/Questionable/Functions/AlliedSocietyQuestFunctions.cs
new file mode 100644 (file)
index 0000000..4fd7041
--- /dev/null
@@ -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<AlliedSocietyQuestFunctions> _logger;
+    private readonly Dictionary<EAlliedSociety, List<NpcData>> _questsByAlliedSociety = [];
+    private readonly Dictionary<(uint NpcDataId, byte Seed, bool OutranksAll), List<QuestId>> _dailyQuests = [];
+
+    public AlliedSocietyQuestFunctions(QuestData questData, ILogger<AlliedSocietyQuestFunctions> logger)
+    {
+        _logger = logger;
+        foreach (var alliedSociety in Enum.GetValues<EAlliedSociety>().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<QuestInfo> quests) in questsByIssuer)
+            {
+                var npcData = new NpcData { IssuerDataId = issuerDataId, AllQuests = quests };
+                if (_questsByAlliedSociety.TryGetValue(alliedSociety, out List<NpcData>? existingNpcs))
+                    existingNpcs.Add(npcData);
+                else
+                    _questsByAlliedSociety[alliedSociety] = [npcData];
+            }
+        }
+    }
+
+    public unsafe List<QuestId> 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<QuestId> 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<QuestId>? 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<QuestId> CalculateAvailableQuests(List<QuestInfo> allQuests, byte seed, bool outranksAll,
+        byte currentRank, bool rankedUp)
+    {
+        List<QuestInfo> eligible = [.. allQuests.Where(q => IsEligible(q, currentRank, rankedUp))];
+        List<QuestInfo> 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<QuestInfo> 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);
+        }
+    }
+}
index 2f752628b41874e5ae5b5568ccc7d28f9f625e35..2f0cb841d3fdfa76907947574c911bffa1057bba 100644 (file)
@@ -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)
index bc3d1838cb011b427e1af3d77fd433ac53c5c913..32f80ff45db1c362047f2fa44e81e0f999e67518 100644 (file)
@@ -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<EClassJob> ClassJobs { get; }
     public bool IsSeasonalEvent { get; }
     public uint NewGamePlusChapter { get; }
index a4eabbaa2924e9b0b2f192662836332b02408a1f..2af5c204a4ccf25387cd21932dca11ab32176d04 100644 (file)
@@ -111,6 +111,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
         serviceCollection.AddSingleton<GameFunctions>();
         serviceCollection.AddSingleton<ChatFunctions>();
         serviceCollection.AddSingleton<QuestFunctions>();
+        serviceCollection.AddSingleton<AlliedSocietyQuestFunctions>();
         serviceCollection.AddSingleton<DalamudReflector>();
 
         serviceCollection.AddSingleton<AetherCurrentData>();
index 270a76d90f728b19f3c28116186640df5ebc8e03..9c51f253f358391243b0e84701ebab485f71d449 100644 (file)
@@ -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))