Second draft for auto-completing quest battles

This commit is contained in:
Liza 2025-02-20 20:45:38 +01:00
parent 92873554cc
commit 097c67ed5d
Signed by: liza
GPG Key ID: 2C41B84815CF6445
41 changed files with 197 additions and 70 deletions

View File

@ -274,7 +274,7 @@ public sealed class RendererPlugin : IDalamudPlugin
locationOverride?.MaximumDistance ?? x.CalculateMaximumDistance(), locationOverride?.MaximumDistance ?? x.CalculateMaximumDistance(),
minimumAngle, maximumAngle, color | 0xFF000000); minimumAngle, maximumAngle, color | 0xFF000000);
drawList.AddText(x.Position, 0xFFFFFFFF, $"{location.Root.Groups.IndexOf(group)} // {node.DataId} / {node.Locations.IndexOf(x)} || {minimumAngle}, {maximumAngle}", 1f); drawList.AddText(x.Position, isUnsaved ? 0xFFFF0000 : 0xFFFFFFFF, $"{location.Root.Groups.IndexOf(group)} // {node.DataId} / {node.Locations.IndexOf(x)} || {minimumAngle}, {maximumAngle}", 1f);
#if false #if false
var a = GatheringMath.CalculateLandingLocation(x, 0, 0); var a = GatheringMath.CalculateLandingLocation(x, 0, 0);
var b = GatheringMath.CalculateLandingLocation(x, 1, 1); var b = GatheringMath.CalculateLandingLocation(x, 1, 1);

View File

@ -126,6 +126,9 @@ internal static class QuestStepExtensions
Assignment(nameof(QuestStep.BossModEnabled), Assignment(nameof(QuestStep.BossModEnabled),
step.BossModEnabled, emptyStep.BossModEnabled) step.BossModEnabled, emptyStep.BossModEnabled)
.AsSyntaxNodeOrToken(), .AsSyntaxNodeOrToken(),
Assignment(nameof(QuestStep.SinglePlayerDutyIndex),
step.SinglePlayerDutyIndex, emptyStep.SinglePlayerDutyIndex)
.AsSyntaxNodeOrToken(),
Assignment(nameof(QuestStep.SkipConditions), step.SkipConditions, Assignment(nameof(QuestStep.SkipConditions), step.SkipConditions,
emptyStep.SkipConditions) emptyStep.SkipConditions)
.AsSyntaxNodeOrToken(), .AsSyntaxNodeOrToken(),

View File

@ -57,6 +57,7 @@
}, },
"TerritoryId": 153, "TerritoryId": 153,
"InteractionType": "SinglePlayerDuty", "InteractionType": "SinglePlayerDuty",
"SinglePlayerDutyIndex": 1,
"Fly": true "Fly": true
} }
] ]

View File

@ -62,6 +62,7 @@
}, },
"TerritoryId": 154, "TerritoryId": 154,
"InteractionType": "SinglePlayerDuty", "InteractionType": "SinglePlayerDuty",
"SinglePlayerDutyIndex": 1,
"AetheryteShortcut": "North Shroud - Fallgourd Float", "AetheryteShortcut": "North Shroud - Fallgourd Float",
"Fly": true "Fly": true
} }

View File

@ -119,7 +119,8 @@
"Z": 29.06836 "Z": 29.06836
}, },
"TerritoryId": 152, "TerritoryId": 152,
"InteractionType": "SinglePlayerDuty" "InteractionType": "SinglePlayerDuty",
"SinglePlayerDutyIndex": 1
} }
] ]
}, },

View File

@ -92,6 +92,7 @@
}, },
"TerritoryId": 130, "TerritoryId": 130,
"InteractionType": "SinglePlayerDuty", "InteractionType": "SinglePlayerDuty",
"SinglePlayerDutyIndex": 1,
"AetheryteShortcut": "Ul'dah" "AetheryteShortcut": "Ul'dah"
} }
] ]

View File

@ -96,7 +96,6 @@
"TerritoryId": 138, "TerritoryId": 138,
"InteractionType": "SinglePlayerDuty", "InteractionType": "SinglePlayerDuty",
"Fly": true, "Fly": true,
"ContentFinderConditionId": 393,
"BossModEnabled": true "BossModEnabled": true
} }
] ]

View File

@ -59,7 +59,6 @@
}, },
"TerritoryId": 401, "TerritoryId": 401,
"InteractionType": "SinglePlayerDuty", "InteractionType": "SinglePlayerDuty",
"ContentFinderConditionId": 395,
"BossModEnabled": true "BossModEnabled": true
} }
] ]

View File

@ -79,7 +79,6 @@
"[Ishgard] The Forgotten Knight", "[Ishgard] The Forgotten Knight",
"[Ishgard] The Tribunal" "[Ishgard] The Tribunal"
], ],
"ContentFinderConditionId": 396,
"BossModEnabled": true "BossModEnabled": true
} }
] ]

View File

@ -29,7 +29,6 @@
}, },
"TerritoryId": 145, "TerritoryId": 145,
"InteractionType": "SinglePlayerDuty", "InteractionType": "SinglePlayerDuty",
"ContentFinderConditionId": 400,
"BossModEnabled": true "BossModEnabled": true
} }
] ]

View File

@ -79,7 +79,6 @@
"TerritoryId": 397, "TerritoryId": 397,
"InteractionType": "SinglePlayerDuty", "InteractionType": "SinglePlayerDuty",
"DisableNavmesh": true, "DisableNavmesh": true,
"ContentFinderConditionId": 397,
"BossModEnabled": true "BossModEnabled": true
} }
] ]

View File

@ -75,7 +75,6 @@
}, },
"TerritoryId": 418, "TerritoryId": 418,
"InteractionType": "SinglePlayerDuty", "InteractionType": "SinglePlayerDuty",
"ContentFinderConditionId": 398,
"BossModEnabled": true "BossModEnabled": true
} }
] ]

View File

@ -57,7 +57,6 @@
"InteractionType": "SinglePlayerDuty", "InteractionType": "SinglePlayerDuty",
"Emote": "lookout", "Emote": "lookout",
"StopDistance": 0.25, "StopDistance": 0.25,
"ContentFinderConditionId": 401,
"BossModEnabled": true "BossModEnabled": true
} }
] ]

View File

@ -48,7 +48,6 @@
"[Idyllshire] Aetheryte Plaza", "[Idyllshire] Aetheryte Plaza",
"[Idyllshire] Epilogue Gate (Eastern Hinterlands)" "[Idyllshire] Epilogue Gate (Eastern Hinterlands)"
], ],
"ContentFinderConditionId": 422,
"BossModEnabled": true "BossModEnabled": true
} }
] ]

View File

@ -69,7 +69,6 @@
}, },
"TerritoryId": 402, "TerritoryId": 402,
"InteractionType": "SinglePlayerDuty", "InteractionType": "SinglePlayerDuty",
"ContentFinderConditionId": 399,
"BossModEnabled": true "BossModEnabled": true
} }
] ]

View File

@ -104,6 +104,7 @@
"StopDistance": 5, "StopDistance": 5,
"TerritoryId": 829, "TerritoryId": 829,
"InteractionType": "SinglePlayerDuty", "InteractionType": "SinglePlayerDuty",
"SinglePlayerDutyIndex": 1,
"DialogueChoices": [ "DialogueChoices": [
{ {
"Type": "List", "Type": "List",

View File

@ -1267,13 +1267,14 @@
}, },
"then": { "then": {
"properties": { "properties": {
"ContentFinderConditionId": {
"type": "integer",
"exclusiveMinimum": 0,
"exclusiveMaximum": 3000
},
"BossModEnabled": { "BossModEnabled": {
"type": "boolean" "type": "boolean"
},
"SinglePlayerDutyIndex": {
"type": "integer",
"minimum": 0,
"maximum": 1,
"description": "If a quest has multiple solo instances (which affects 5 quests total), indicates which one this is"
} }
} }
} }

View File

@ -91,6 +91,8 @@ public abstract class ElementId : IComparable<ElementId>, IEquatable<ElementId>
public sealed class QuestId(ushort value) : ElementId(value) public sealed class QuestId(ushort value) : ElementId(value)
{ {
public static QuestId FromRowId(uint rowId) => new((ushort)(rowId & 0xFFFF));
public override string ToString() public override string ToString()
{ {
return Value.ToString(CultureInfo.InvariantCulture); return Value.ToString(CultureInfo.InvariantCulture);

View File

@ -76,6 +76,7 @@ public sealed class QuestStep
public uint? ContentFinderConditionId { get; set; } public uint? ContentFinderConditionId { get; set; }
public bool AutoDutyEnabled { get; set; } public bool AutoDutyEnabled { get; set; }
public bool BossModEnabled { get; set; } public bool BossModEnabled { get; set; }
public byte SinglePlayerDutyIndex { get; set; }
public SkipConditions? SkipConditions { get; set; } public SkipConditions? SkipConditions { get; set; }
public List<List<QuestWorkValue>?> RequiredQuestVariables { get; set; } = new(); public List<List<QuestWorkValue>?> RequiredQuestVariables { get; set; } = new();

View File

@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "/liza/Questionable/raw/branch/master/Questionable.Model/common-aethernetshard.json", "$id": "https://git.carvel.li/liza/Questionable/raw/branch/master/Questionable.Model/common-aethernetshard.json",
"type": "string", "type": "string",
"enum": [ "enum": [
"[Gridania] Aetheryte Plaza", "[Gridania] Aetheryte Plaza",

View File

@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "/liza/Questionable/raw/branch/master/Questionable.Model/common-aetheryte.json", "$id": "https://git.carvel.li/liza/Questionable/raw/branch/master/Questionable.Model/common-aetheryte.json",
"type": "string", "type": "string",
"enum": [ "enum": [
"Gridania", "Gridania",

View File

@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "/liza/Questionable/raw/branch/master/Questionable.Model/common-classjob.json", "$id": "https://git.carvel.li//liza/Questionable/raw/branch/master/Questionable.Model/common-classjob.json",
"type": "string", "type": "string",
"enum": [ "enum": [
"Gladiator", "Gladiator",

View File

@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "/liza/Questionable/raw/branch/master/Questionable.Model/common-completionflags.json", "$id": "https://git.carvel.li//liza/Questionable/raw/branch/master/Questionable.Model/common-completionflags.json",
"type": "array", "type": "array",
"description": "Quest Variables that dictate whether or not this step is skipped: null is don't check, positive values need to be set, negative values need to be unset", "description": "Quest Variables that dictate whether or not this step is skipped: null is don't check, positive values need to be set, negative values need to be unset",
"items": { "items": {

View File

@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "/liza/Questionable/raw/branch/master/Questionable.Model/common-vector3.json", "$id": "https://git.carvel.li//liza/Questionable/raw/branch/master/Questionable.Model/common-vector3.json",
"type": "object", "type": "object",
"description": "Position in the world", "description": "Position in the world",
"properties": { "properties": {

View File

@ -14,6 +14,7 @@ internal sealed class Configuration : IPluginConfiguration
public int PluginSetupCompleteVersion { get; set; } public int PluginSetupCompleteVersion { get; set; }
public GeneralConfiguration General { get; } = new(); public GeneralConfiguration General { get; } = new();
public DutyConfiguration Duties { get; } = new(); public DutyConfiguration Duties { get; } = new();
public SoloDutyConfiguration SoloDuties { get; } = new();
public NotificationConfiguration Notifications { get; } = new(); public NotificationConfiguration Notifications { get; } = new();
public AdvancedConfiguration Advanced { get; } = new(); public AdvancedConfiguration Advanced { get; } = new();
public WindowConfig DebugWindowConfig { get; } = new(); public WindowConfig DebugWindowConfig { get; } = new();
@ -41,6 +42,13 @@ internal sealed class Configuration : IPluginConfiguration
public HashSet<uint> BlacklistedDutyCfcIds { get; set; } = []; public HashSet<uint> BlacklistedDutyCfcIds { get; set; } = [];
} }
internal sealed class SoloDutyConfiguration
{
public bool RunSoloInstancesWithBossMod { get; set; }
public HashSet<uint> WhitelistedSoloDutyCfcIds { get; set; } = [];
public HashSet<uint> BlacklistedSoloDutyCfcIds { get; set; } = [];
}
internal sealed class NotificationConfiguration internal sealed class NotificationConfiguration
{ {
public bool Enabled { get; set; } = true; public bool Enabled { get; set; } = true;

View File

@ -27,6 +27,7 @@ internal sealed class QuestRegistry
private readonly JsonSchemaValidator _jsonSchemaValidator; private readonly JsonSchemaValidator _jsonSchemaValidator;
private readonly ILogger<QuestRegistry> _logger; private readonly ILogger<QuestRegistry> _logger;
private readonly LeveData _leveData; private readonly LeveData _leveData;
private readonly TerritoryData _territoryData;
private readonly ICallGateProvider<object> _reloadDataIpc; private readonly ICallGateProvider<object> _reloadDataIpc;
private readonly Dictionary<ElementId, Quest> _quests = []; private readonly Dictionary<ElementId, Quest> _quests = [];
@ -34,7 +35,7 @@ internal sealed class QuestRegistry
public QuestRegistry(IDalamudPluginInterface pluginInterface, QuestData questData, public QuestRegistry(IDalamudPluginInterface pluginInterface, QuestData questData,
QuestValidator questValidator, JsonSchemaValidator jsonSchemaValidator, QuestValidator questValidator, JsonSchemaValidator jsonSchemaValidator,
ILogger<QuestRegistry> logger, LeveData leveData) ILogger<QuestRegistry> logger, LeveData leveData, TerritoryData territoryData)
{ {
_pluginInterface = pluginInterface; _pluginInterface = pluginInterface;
_questData = questData; _questData = questData;
@ -42,6 +43,7 @@ internal sealed class QuestRegistry
_jsonSchemaValidator = jsonSchemaValidator; _jsonSchemaValidator = jsonSchemaValidator;
_logger = logger; _logger = logger;
_leveData = leveData; _leveData = leveData;
_territoryData = territoryData;
_reloadDataIpc = _pluginInterface.GetIpcProvider<object>("Questionable.ReloadData"); _reloadDataIpc = _pluginInterface.GetIpcProvider<object>("Questionable.ReloadData");
} }
@ -150,10 +152,15 @@ internal sealed class QuestRegistry
foreach (var quest in _quests.Values) foreach (var quest in _quests.Values)
{ {
foreach (var dutyStep in quest.AllSteps().Where(x => foreach (var dutyStep in quest.AllSteps().Where(x =>
x.Step.InteractionType is EInteractionType.Duty or EInteractionType.SinglePlayerDuty x.Step.InteractionType is EInteractionType.Duty or EInteractionType.SinglePlayerDuty))
&& x.Step.ContentFinderConditionId != null))
{ {
_contentFinderConditionIds[dutyStep.Step.ContentFinderConditionId!.Value] = (quest.Id, dutyStep.Step); if (dutyStep.Step is { InteractionType: EInteractionType.Duty, ContentFinderConditionId: not null })
_contentFinderConditionIds[dutyStep.Step.ContentFinderConditionId!.Value] =
(quest.Id, dutyStep.Step);
else if (dutyStep.Step.InteractionType == EInteractionType.SinglePlayerDuty &&
_territoryData.TryGetContentFinderConditionForSoloInstance(quest.Id,
dutyStep.Step.SinglePlayerDutyIndex, out var cfcData))
_contentFinderConditionIds[cfcData.ContentFinderConditionId] = (quest.Id, dutyStep.Step);
} }
} }
} }

View File

@ -14,6 +14,7 @@ internal static class SendNotification
internal sealed class Factory( internal sealed class Factory(
AutomatonIpc automatonIpc, AutomatonIpc automatonIpc,
AutoDutyIpc autoDutyIpc, AutoDutyIpc autoDutyIpc,
BossModIpc bossModIpc,
TerritoryData territoryData) : SimpleTaskFactory TerritoryData territoryData) : SimpleTaskFactory
{ {
public override ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step) public override ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
@ -26,7 +27,7 @@ internal static class SendNotification
new Task(step.InteractionType, step.ContentFinderConditionId.HasValue new Task(step.InteractionType, step.ContentFinderConditionId.HasValue
? territoryData.GetContentFinderCondition(step.ContentFinderConditionId.Value)?.Name ? territoryData.GetContentFinderCondition(step.ContentFinderConditionId.Value)?.Name
: step.Comment), : step.Comment),
EInteractionType.SinglePlayerDuty when !step.BossModEnabled => EInteractionType.SinglePlayerDuty when !bossModIpc.IsConfiguredToRunSoloInstance(quest.Id, step.SinglePlayerDutyIndex, step.BossModEnabled) =>
new Task(step.InteractionType, quest.Info.Name), new Task(step.InteractionType, quest.Info.Name),
_ => null, _ => null,
}; };

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game;
using Questionable.Controller.Steps.Shared; using Questionable.Controller.Steps.Shared;
using Questionable.Data; using Questionable.Data;
using Questionable.External; using Questionable.External;
@ -11,20 +12,23 @@ namespace Questionable.Controller.Steps.Interactions;
internal static class SinglePlayerDuty internal static class SinglePlayerDuty
{ {
internal sealed class Factory : ITaskFactory internal sealed class Factory(
BossModIpc bossModIpc,
TerritoryData territoryData) : ITaskFactory
{ {
public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step) public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
{ {
if (step.InteractionType != EInteractionType.SinglePlayerDuty) if (step.InteractionType != EInteractionType.SinglePlayerDuty)
yield break; yield break;
if (step.BossModEnabled) if (bossModIpc.IsConfiguredToRunSoloInstance(quest.Id, step.SinglePlayerDutyIndex, step.BossModEnabled))
{ {
ArgumentNullException.ThrowIfNull(step.ContentFinderConditionId); if (!territoryData.TryGetContentFinderConditionForSoloInstance(quest.Id, step.SinglePlayerDutyIndex, out var cfcData))
throw new TaskException("Failed to get content finder condition for solo instance");
yield return new StartSinglePlayerDuty(step.ContentFinderConditionId.Value); yield return new StartSinglePlayerDuty(cfcData.ContentFinderConditionId);
yield return new EnableAi(); yield return new EnableAi();
yield return new WaitSinglePlayerDuty(step.ContentFinderConditionId.Value); yield return new WaitSinglePlayerDuty(cfcData.ContentFinderConditionId);
yield return new DisableAi(); yield return new DisableAi();
yield return new WaitAtEnd.WaitNextStepOrSequence(); yield return new WaitAtEnd.WaitNextStepOrSequence();
} }
@ -36,19 +40,13 @@ internal static class SinglePlayerDuty
public override string ToString() => $"Wait(BossMod, entered instance {ContentFinderConditionId})"; public override string ToString() => $"Wait(BossMod, entered instance {ContentFinderConditionId})";
} }
internal sealed class StartSinglePlayerDutyExecutor( internal sealed class StartSinglePlayerDutyExecutor : TaskExecutor<StartSinglePlayerDuty>
TerritoryData territoryData,
IClientState clientState) : TaskExecutor<StartSinglePlayerDuty>
{ {
protected override bool Start() => true; protected override bool Start() => true;
public override ETaskResult Update() public override unsafe ETaskResult Update()
{ {
if (!territoryData.TryGetContentFinderCondition(Task.ContentFinderConditionId, return GameMain.Instance()->CurrentContentFinderConditionId == Task.ContentFinderConditionId
out var cfcData))
throw new TaskException("Failed to get territory ID for content finder condition");
return clientState.TerritoryType == cfcData.TerritoryId
? ETaskResult.TaskComplete ? ETaskResult.TaskComplete
: ETaskResult.StillRunning; : ETaskResult.StillRunning;
} }
@ -81,19 +79,13 @@ internal static class SinglePlayerDuty
} }
internal sealed class WaitSinglePlayerDutyExecutor( internal sealed class WaitSinglePlayerDutyExecutor(
TerritoryData territoryData,
IClientState clientState,
BossModIpc bossModIpc) : TaskExecutor<WaitSinglePlayerDuty>, IStoppableTaskExecutor BossModIpc bossModIpc) : TaskExecutor<WaitSinglePlayerDuty>, IStoppableTaskExecutor
{ {
protected override bool Start() => true; protected override bool Start() => true;
public override ETaskResult Update() public override unsafe ETaskResult Update()
{ {
if (!territoryData.TryGetContentFinderCondition(Task.ContentFinderConditionId, return GameMain.Instance()->CurrentContentFinderConditionId != Task.ContentFinderConditionId
out var cfcData))
throw new TaskException("Failed to get territory ID for content finder condition");
return clientState.TerritoryType != cfcData.TerritoryId
? ETaskResult.TaskComplete ? ETaskResult.TaskComplete
: ETaskResult.StillRunning; : ETaskResult.StillRunning;
} }

View File

@ -21,7 +21,8 @@ internal static class WaitAtEnd
IClientState clientState, IClientState clientState,
ICondition condition, ICondition condition,
TerritoryData territoryData, TerritoryData territoryData,
AutoDutyIpc autoDutyIpc) AutoDutyIpc autoDutyIpc,
BossModIpc bossModIpc)
: ITaskFactory : ITaskFactory
{ {
public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step) public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
@ -53,7 +54,7 @@ internal static class WaitAtEnd
return [new WaitNextStepOrSequence()]; return [new WaitNextStepOrSequence()];
case EInteractionType.Duty when !autoDutyIpc.IsConfiguredToRunContent(step.ContentFinderConditionId, step.AutoDutyEnabled): case EInteractionType.Duty when !autoDutyIpc.IsConfiguredToRunContent(step.ContentFinderConditionId, step.AutoDutyEnabled):
case EInteractionType.SinglePlayerDuty when !step.BossModEnabled: case EInteractionType.SinglePlayerDuty when !bossModIpc.IsConfiguredToRunSoloInstance(quest.Id, step.SinglePlayerDutyIndex, step.BossModEnabled):
return [new EndAutomation()]; return [new EndAutomation()];
case EInteractionType.WalkTo: case EInteractionType.WalkTo:

View File

@ -23,17 +23,17 @@ internal sealed class JournalData
var genreLimsa = new Genre(uint.MaxValue - 3, "Starting in Limsa Lominsa", 1, var genreLimsa = new Genre(uint.MaxValue - 3, "Starting in Limsa Lominsa", 1,
new uint[] { 108, 109 }.Concat(limsaStart.QuestRedoParam.Select(x => x.Quest.RowId)) new uint[] { 108, 109 }.Concat(limsaStart.QuestRedoParam.Select(x => x.Quest.RowId))
.Where(x => x != 0) .Where(x => x != 0)
.Select(x => questData.GetQuestInfo(new QuestId((ushort)(x & 0xFFFF)))) .Select(x => questData.GetQuestInfo(QuestId.FromRowId(x)))
.ToList()); .ToList());
var genreGridania = new Genre(uint.MaxValue - 2, "Starting in Gridania", 1, var genreGridania = new Genre(uint.MaxValue - 2, "Starting in Gridania", 1,
new uint[] { 85, 123, 124 }.Concat(gridaniaStart.QuestRedoParam.Select(x => x.Quest.RowId)) new uint[] { 85, 123, 124 }.Concat(gridaniaStart.QuestRedoParam.Select(x => x.Quest.RowId))
.Where(x => x != 0) .Where(x => x != 0)
.Select(x => questData.GetQuestInfo(new QuestId((ushort)(x & 0xFFFF)))) .Select(x => questData.GetQuestInfo(QuestId.FromRowId(x)))
.ToList()); .ToList());
var genreUldah = new Genre(uint.MaxValue - 1, "Starting in Ul'dah", 1, var genreUldah = new Genre(uint.MaxValue - 1, "Starting in Ul'dah", 1,
new uint[] { 568, 569, 570 }.Concat(uldahStart.QuestRedoParam.Select(x => x.Quest.RowId)) new uint[] { 568, 569, 570 }.Concat(uldahStart.QuestRedoParam.Select(x => x.Quest.RowId))
.Where(x => x != 0) .Where(x => x != 0)
.Select(x => questData.GetQuestInfo(new QuestId((ushort)(x & 0xFFFF)))) .Select(x => questData.GetQuestInfo(QuestId.FromRowId(x)))
.ToList()); .ToList());
genres.InsertRange(0, [genreLimsa, genreGridania, genreUldah]); genres.InsertRange(0, [genreLimsa, genreGridania, genreUldah]);
genres.Single(x => x.Id == 1) genres.Single(x => x.Id == 1)

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
@ -7,6 +8,7 @@ using Dalamud.Game;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using Dalamud.Utility; using Dalamud.Utility;
using Lumina.Excel.Sheets; using Lumina.Excel.Sheets;
using Questionable.Model.Questing;
namespace Questionable.Data; namespace Questionable.Data;
@ -17,6 +19,7 @@ internal sealed class TerritoryData
private readonly ImmutableDictionary<ushort, uint> _dutyTerritories; private readonly ImmutableDictionary<ushort, uint> _dutyTerritories;
private readonly ImmutableDictionary<uint, string> _instanceNames; private readonly ImmutableDictionary<uint, string> _instanceNames;
private readonly ImmutableDictionary<uint, ContentFinderConditionData> _contentFinderConditions; private readonly ImmutableDictionary<uint, ContentFinderConditionData> _contentFinderConditions;
private readonly ImmutableDictionary<(ElementId QuestId, byte Index), uint> _questsToCfc;
public TerritoryData(IDataManager dataManager) public TerritoryData(IDataManager dataManager)
{ {
@ -48,6 +51,13 @@ internal sealed class TerritoryData
.Where(x => x.RowId > 0 && x.Content.RowId != 0 && x.ContentLinkType is 1 or 5 && x.ContentType.RowId != 6) .Where(x => x.RowId > 0 && x.Content.RowId != 0 && x.ContentLinkType is 1 or 5 && x.ContentType.RowId != 6)
.Select(x => new ContentFinderConditionData(x, dataManager.Language)) .Select(x => new ContentFinderConditionData(x, dataManager.Language))
.ToImmutableDictionary(x => x.ContentFinderConditionId, x => x); .ToImmutableDictionary(x => x.ContentFinderConditionId, x => x);
_questsToCfc = dataManager.GetExcelSheet<Quest>()
.Where(x => x is { RowId: > 0, IssuerLocation.RowId: > 0 })
.SelectMany(GetQuestBattles)
.Select(x => (x.QuestId, x.Index,
CfcId: LookupContentFinderConditionForQuestBattle(dataManager, x.QuestBattleId)))
.ToImmutableDictionary(x => (x.QuestId, x.Index), x => x.CfcId);
} }
public string? GetName(ushort territoryId) => _territoryNames.GetValueOrDefault(territoryId); public string? GetName(ushort territoryId) => _territoryNames.GetValueOrDefault(territoryId);
@ -77,6 +87,18 @@ internal sealed class TerritoryData
[NotNullWhen(true)] out ContentFinderConditionData? contentFinderConditionData) => [NotNullWhen(true)] out ContentFinderConditionData? contentFinderConditionData) =>
_contentFinderConditions.TryGetValue(cfcId, out contentFinderConditionData); _contentFinderConditions.TryGetValue(cfcId, out contentFinderConditionData);
public bool TryGetContentFinderConditionForSoloInstance(ElementId questId, byte index,
[NotNullWhen(true)] out ContentFinderConditionData? contentFinderConditionData)
{
if (_questsToCfc.TryGetValue((questId, index), out uint cfcId))
return _contentFinderConditions.TryGetValue(cfcId, out contentFinderConditionData);
else
{
contentFinderConditionData = null;
return false;
}
}
private static string FixName(string name, ClientLanguage language) private static string FixName(string name, ClientLanguage language)
{ {
if (string.IsNullOrEmpty(name) || language != ClientLanguage.English) if (string.IsNullOrEmpty(name) || language != ClientLanguage.English)
@ -85,6 +107,27 @@ internal sealed class TerritoryData
return string.Concat(name[0].ToString().ToUpper(CultureInfo.InvariantCulture), name.AsSpan(1)); return string.Concat(name[0].ToString().ToUpper(CultureInfo.InvariantCulture), name.AsSpan(1));
} }
private static IEnumerable<(ElementId QuestId, byte Index, uint QuestBattleId)> GetQuestBattles(Quest quest)
{
foreach (Quest.QuestParamsStruct t in quest.QuestParams)
{
if (t.ScriptInstruction == "QUESTBATTLE0")
yield return (QuestId.FromRowId(quest.RowId), 0, t.ScriptArg);
else if (t.ScriptInstruction == "QUESTBATTLE1")
yield return (QuestId.FromRowId(quest.RowId), 1, t.ScriptArg);
else if (t.ScriptInstruction.IsEmpty)
break;
}
}
private static uint LookupContentFinderConditionForQuestBattle(IDataManager dataManager, uint questBattleId)
{
if (questBattleId >= 5000)
return dataManager.GetExcelSheet<InstanceContent>().GetRow(questBattleId).Order;
else
return dataManager.GetExcelSheet<QuestBattleResident>().GetRow(questBattleId).Unknown0;
}
public sealed record ContentFinderConditionData( public sealed record ContentFinderConditionData(
uint ContentFinderConditionId, uint ContentFinderConditionId,
string Name, string Name,

View File

@ -31,7 +31,7 @@ internal sealed class AutoDutyIpc
_stop = pluginInterface.GetIpcSubscriber<object>("AutoDuty.Stop"); _stop = pluginInterface.GetIpcSubscriber<object>("AutoDuty.Stop");
} }
public bool IsConfiguredToRunContent(uint? cfcId, bool autoDutyEnabled) public bool IsConfiguredToRunContent(uint? cfcId, bool enabledByDefault)
{ {
if (cfcId == null) if (cfcId == null)
return false; return false;
@ -46,7 +46,7 @@ internal sealed class AutoDutyIpc
_territoryData.TryGetContentFinderCondition(cfcId.Value, out _)) _territoryData.TryGetContentFinderCondition(cfcId.Value, out _))
return true; return true;
return autoDutyEnabled && HasPath(cfcId.Value); return enabledByDefault && HasPath(cfcId.Value);
} }
public bool HasPath(uint cfcId) public bool HasPath(uint cfcId)

View File

@ -2,22 +2,33 @@ using Dalamud.Plugin;
using Dalamud.Plugin.Ipc; using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Ipc.Exceptions; using Dalamud.Plugin.Ipc.Exceptions;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using Questionable.Data;
using Questionable.Model.Questing;
namespace Questionable.External; namespace Questionable.External;
internal sealed class BossModIpc internal sealed class BossModIpc
{ {
private readonly ICommandManager _commandManager;
private const string Name = "BossMod"; private const string Name = "BossMod";
private readonly Configuration _configuration;
private readonly ICommandManager _commandManager;
private readonly TerritoryData _territoryData;
private readonly ICallGateSubscriber<string, string?> _getPreset; private readonly ICallGateSubscriber<string, string?> _getPreset;
private readonly ICallGateSubscriber<string, bool, bool> _createPreset; private readonly ICallGateSubscriber<string, bool, bool> _createPreset;
private readonly ICallGateSubscriber<string, bool> _setPreset; private readonly ICallGateSubscriber<string, bool> _setPreset;
private readonly ICallGateSubscriber<bool> _clearPreset; private readonly ICallGateSubscriber<bool> _clearPreset;
public BossModIpc(IDalamudPluginInterface pluginInterface, ICommandManager commandManager) public BossModIpc(
IDalamudPluginInterface pluginInterface,
Configuration configuration,
ICommandManager commandManager,
TerritoryData territoryData)
{ {
_configuration = configuration;
_commandManager = commandManager; _commandManager = commandManager;
_territoryData = territoryData;
_getPreset = pluginInterface.GetIpcSubscriber<string, string?>($"{Name}.Presets.Get"); _getPreset = pluginInterface.GetIpcSubscriber<string, string?>($"{Name}.Presets.Get");
_createPreset = pluginInterface.GetIpcSubscriber<string, bool, bool>($"{Name}.Presets.Create"); _createPreset = pluginInterface.GetIpcSubscriber<string, bool, bool>($"{Name}.Presets.Create");
_setPreset = pluginInterface.GetIpcSubscriber<string, bool>($"{Name}.Presets.SetActive"); _setPreset = pluginInterface.GetIpcSubscriber<string, bool>($"{Name}.Presets.SetActive");
@ -70,4 +81,21 @@ internal sealed class BossModIpc
_commandManager.ProcessCommand("/vbm cfg ZoneModuleConfig EnableQuestBattles false"); _commandManager.ProcessCommand("/vbm cfg ZoneModuleConfig EnableQuestBattles false");
ClearPreset(); ClearPreset();
} }
public bool IsConfiguredToRunSoloInstance(ElementId questId, byte dutyIndex, bool enabledByDefault)
{
if (!_configuration.SoloDuties.RunSoloInstancesWithBossMod)
return false;
if (!_territoryData.TryGetContentFinderConditionForSoloInstance(questId, dutyIndex, out var cfcData))
return false;
if (_configuration.SoloDuties.BlacklistedSoloDutyCfcIds.Contains(cfcData.ContentFinderConditionId))
return false;
if (_configuration.SoloDuties.WhitelistedSoloDutyCfcIds.Contains(cfcData.ContentFinderConditionId))
return true;
return enabledByDefault;
}
} }

View File

@ -41,10 +41,10 @@ internal sealed class QuestionableIpc : IDisposable
eventInfoComponent.GetCurrentlyActiveEventQuests().Select(q => q.ToString()).ToList()); eventInfoComponent.GetCurrentlyActiveEventQuests().Select(q => q.ToString()).ToList());
_startQuest = pluginInterface.GetIpcProvider<string, bool>(IpcStartQuest); _startQuest = pluginInterface.GetIpcProvider<string, bool>(IpcStartQuest);
_startQuest.RegisterFunc((string questId) => StartQuest(questController, questRegistry, questId, false)); _startQuest.RegisterFunc((questId) => StartQuest(questController, questRegistry, questId, false));
_startSingleQuest = pluginInterface.GetIpcProvider<string, bool>(IpcStartSingleQuest); _startSingleQuest = pluginInterface.GetIpcProvider<string, bool>(IpcStartSingleQuest);
_startSingleQuest.RegisterFunc((string questId) => StartQuest(questController, questRegistry, questId, true)); _startSingleQuest.RegisterFunc((questId) => StartQuest(questController, questRegistry, questId, true));
} }
private static bool StartQuest(QuestController qc, QuestRegistry qr, string questId, bool single) private static bool StartQuest(QuestController qc, QuestRegistry qr, string questId, bool single)
@ -63,6 +63,7 @@ internal sealed class QuestionableIpc : IDisposable
public void Dispose() public void Dispose()
{ {
_startSingleQuest.UnregisterFunc();
_startQuest.UnregisterFunc(); _startQuest.UnregisterFunc();
_getCurrentlyActiveEventQuests.UnregisterFunc(); _getCurrentlyActiveEventQuests.UnregisterFunc();
_getCurrentQuestId.UnregisterFunc(); _getCurrentQuestId.UnregisterFunc();

View File

@ -7,6 +7,7 @@ using Lumina.Excel.Sheets;
using Questionable.Model.Questing; using Questionable.Model.Questing;
using ExcelQuest = Lumina.Excel.Sheets.Quest; using ExcelQuest = Lumina.Excel.Sheets.Quest;
using GrandCompany = FFXIVClientStructs.FFXIV.Client.UI.Agent.GrandCompany; using GrandCompany = FFXIVClientStructs.FFXIV.Client.UI.Agent.GrandCompany;
using QQuestId = Questionable.Model.Questing.QuestId;
namespace Questionable.Model; namespace Questionable.Model;
@ -14,7 +15,7 @@ internal sealed class QuestInfo : IQuestInfo
{ {
public QuestInfo(ExcelQuest quest, uint newGamePlusChapter, byte startingCity, JournalGenreOverrides journalGenreOverrides) public QuestInfo(ExcelQuest quest, uint newGamePlusChapter, byte startingCity, JournalGenreOverrides journalGenreOverrides)
{ {
QuestId = new QuestId((ushort)(quest.RowId & 0xFFFF)); QuestId = QQuestId.FromRowId(quest.RowId);
string suffix = QuestId.Value switch string suffix = QuestId.Value switch
{ {
@ -41,15 +42,15 @@ internal sealed class QuestInfo : IQuestInfo
PreviousQuests = PreviousQuests =
new List<PreviousQuestInfo> new List<PreviousQuestInfo>
{ {
new(ReplaceOldQuestIds((ushort)(quest.PreviousQuest[0].RowId & 0xFFFF)), quest.Unknown7), new(ReplaceOldQuestIds(QQuestId.FromRowId(quest.PreviousQuest[0].RowId)), quest.Unknown7),
new(ReplaceOldQuestIds((ushort)(quest.PreviousQuest[1].RowId & 0xFFFF))), new(ReplaceOldQuestIds(QQuestId.FromRowId(quest.PreviousQuest[1].RowId))),
new(ReplaceOldQuestIds((ushort)(quest.PreviousQuest[2].RowId & 0xFFFF))) new(ReplaceOldQuestIds(QQuestId.FromRowId(quest.PreviousQuest[2].RowId)))
} }
.Where(x => x.QuestId.Value != 0) .Where(x => x.QuestId.Value != 0)
.ToImmutableList(); .ToImmutableList();
PreviousQuestJoin = (EQuestJoin)quest.PreviousQuestJoin; PreviousQuestJoin = (EQuestJoin)quest.PreviousQuestJoin;
QuestLocks = quest.QuestLock QuestLocks = quest.QuestLock
.Select(x => new QuestId((ushort)(x.RowId & 0xFFFFF))) .Select(x => QQuestId.FromRowId(x.RowId))
.Where(x => x.Value != 0) .Where(x => x.Value != 0)
.ToImmutableList(); .ToImmutableList();
QuestLockJoin = (EQuestJoin)quest.QuestLockJoin; QuestLockJoin = (EQuestJoin)quest.QuestLockJoin;
@ -85,13 +86,13 @@ internal sealed class QuestInfo : IQuestInfo
Expansion = (EExpansionVersion)quest.Expansion.RowId; Expansion = (EExpansionVersion)quest.Expansion.RowId;
} }
private static QuestId ReplaceOldQuestIds(ushort questId) private static QuestId ReplaceOldQuestIds(QuestId questId)
{ {
return new QuestId(questId switch return questId.Value switch
{ {
524 => 4522, 524 => new QuestId(4522),
_ => questId, _ => questId,
}); };
} }
public ElementId QuestId { get; } public ElementId QuestId { get; }

View File

@ -3,6 +3,7 @@ using System.Collections.Immutable;
using LLib.GameData; using LLib.GameData;
using Lumina.Excel.Sheets; using Lumina.Excel.Sheets;
using Questionable.Model.Questing; using Questionable.Model.Questing;
using QQuestId = Questionable.Model.Questing.QuestId;
namespace Questionable.Model; namespace Questionable.Model;
@ -16,7 +17,7 @@ internal sealed class SatisfactionSupplyInfo : IQuestInfo
Level = npc.LevelUnlock; Level = npc.LevelUnlock;
SortKey = QuestId.Value; SortKey = QuestId.Value;
Expansion = (EExpansionVersion)npc.QuestRequired.Value.Expansion.RowId; Expansion = (EExpansionVersion)npc.QuestRequired.Value.Expansion.RowId;
PreviousQuests = [new PreviousQuestInfo(new QuestId((ushort)(npc.QuestRequired.RowId & 0xFFFF)))]; PreviousQuests = [new PreviousQuestInfo(QQuestId.FromRowId(npc.QuestRequired.RowId))];
} }
public ElementId QuestId { get; } public ElementId QuestId { get; }

View File

@ -311,6 +311,7 @@ public sealed class QuestionablePlugin : IDalamudPlugin
serviceCollection.AddSingleton<IQuestValidator, AethernetShortcutValidator>(); serviceCollection.AddSingleton<IQuestValidator, AethernetShortcutValidator>();
serviceCollection.AddSingleton<IQuestValidator, DialogueChoiceValidator>(); serviceCollection.AddSingleton<IQuestValidator, DialogueChoiceValidator>();
serviceCollection.AddSingleton<IQuestValidator, ClassQuestShouldHaveShortcutValidator>(); serviceCollection.AddSingleton<IQuestValidator, ClassQuestShouldHaveShortcutValidator>();
serviceCollection.AddSingleton<IQuestValidator, UniqueSinglePlayerInstanceValidator>();
serviceCollection.AddSingleton<JsonSchemaValidator>(); serviceCollection.AddSingleton<JsonSchemaValidator>();
serviceCollection.AddSingleton<IQuestValidator>(sp => sp.GetRequiredService<JsonSchemaValidator>()); serviceCollection.AddSingleton<IQuestValidator>(sp => sp.GetRequiredService<JsonSchemaValidator>());
} }

View File

@ -18,4 +18,5 @@ public enum EIssueType
InvalidAethernetShortcut, InvalidAethernetShortcut,
InvalidExcelRef, InvalidExcelRef,
ClassQuestWithoutAetheryteShortcut, ClassQuestWithoutAetheryteShortcut,
DuplicateSinglePlayerInstance,
} }

View File

@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.Linq;
using Questionable.Model;
using Questionable.Model.Questing;
namespace Questionable.Validation.Validators;
internal sealed class UniqueSinglePlayerInstanceValidator : IQuestValidator
{
public IEnumerable<ValidationIssue> Validate(Quest quest)
{
var singlePlayerInstances = quest.AllSteps()
.Where(x => x.Step.InteractionType == EInteractionType.SinglePlayerDuty)
.Select(x => (x.Sequence, x.StepId, x.Step.SinglePlayerDutyIndex))
.ToList();
if (singlePlayerInstances.DistinctBy(x => x.SinglePlayerDutyIndex).Count() < singlePlayerInstances.Count)
{
foreach (var singlePlayerInstance in singlePlayerInstances)
{
yield return new ValidationIssue
{
ElementId = quest.Id,
Sequence = (byte)singlePlayerInstance.Sequence.Sequence,
Step = singlePlayerInstance.StepId,
Type = EIssueType.DuplicateSinglePlayerInstance,
Severity = EIssueSeverity.Error,
Description = $"Duplicate singleplayer duty index: {singlePlayerInstance.SinglePlayerDutyIndex}",
};
}
}
}
}

View File

@ -119,7 +119,7 @@ internal sealed class QuestSelectionWindow : LWindow
foreach (var unacceptedQuest in Map.Instance()->UnacceptedQuestMarkers) foreach (var unacceptedQuest in Map.Instance()->UnacceptedQuestMarkers)
{ {
QuestId questId = new QuestId((ushort)(unacceptedQuest.ObjectiveId & 0xFFFF)); QuestId questId = QuestId.FromRowId(unacceptedQuest.ObjectiveId);
if (_quests.All(q => q.QuestId != questId)) if (_quests.All(q => q.QuestId != questId))
_quests.Add(_questData.GetQuestInfo(questId)); _quests.Add(_questData.GetQuestInfo(questId));
} }

7
global.json Normal file
View File

@ -0,0 +1,7 @@
{
"sdk": {
"version": "8.0.0",
"rollForward": "latestMinor",
"allowPrerelease": false
}
}