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(),
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
var a = GatheringMath.CalculateLandingLocation(x, 0, 0);
var b = GatheringMath.CalculateLandingLocation(x, 1, 1);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1267,13 +1267,14 @@
},
"then": {
"properties": {
"ContentFinderConditionId": {
"type": "integer",
"exclusiveMinimum": 0,
"exclusiveMaximum": 3000
},
"BossModEnabled": {
"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 static QuestId FromRowId(uint rowId) => new((ushort)(rowId & 0xFFFF));
public override string ToString()
{
return Value.ToString(CultureInfo.InvariantCulture);

View File

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

View File

@ -1,6 +1,6 @@
{
"$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",
"enum": [
"[Gridania] Aetheryte Plaza",

View File

@ -1,6 +1,6 @@
{
"$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",
"enum": [
"Gridania",

View File

@ -1,6 +1,6 @@
{
"$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",
"enum": [
"Gladiator",

View File

@ -1,6 +1,6 @@
{
"$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",
"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": {

View File

@ -1,6 +1,6 @@
{
"$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",
"description": "Position in the world",
"properties": {

View File

@ -14,6 +14,7 @@ internal sealed class Configuration : IPluginConfiguration
public int PluginSetupCompleteVersion { get; set; }
public GeneralConfiguration General { get; } = new();
public DutyConfiguration Duties { get; } = new();
public SoloDutyConfiguration SoloDuties { get; } = new();
public NotificationConfiguration Notifications { get; } = new();
public AdvancedConfiguration Advanced { get; } = new();
public WindowConfig DebugWindowConfig { get; } = new();
@ -41,6 +42,13 @@ internal sealed class Configuration : IPluginConfiguration
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
{
public bool Enabled { get; set; } = true;

View File

@ -27,6 +27,7 @@ internal sealed class QuestRegistry
private readonly JsonSchemaValidator _jsonSchemaValidator;
private readonly ILogger<QuestRegistry> _logger;
private readonly LeveData _leveData;
private readonly TerritoryData _territoryData;
private readonly ICallGateProvider<object> _reloadDataIpc;
private readonly Dictionary<ElementId, Quest> _quests = [];
@ -34,7 +35,7 @@ internal sealed class QuestRegistry
public QuestRegistry(IDalamudPluginInterface pluginInterface, QuestData questData,
QuestValidator questValidator, JsonSchemaValidator jsonSchemaValidator,
ILogger<QuestRegistry> logger, LeveData leveData)
ILogger<QuestRegistry> logger, LeveData leveData, TerritoryData territoryData)
{
_pluginInterface = pluginInterface;
_questData = questData;
@ -42,6 +43,7 @@ internal sealed class QuestRegistry
_jsonSchemaValidator = jsonSchemaValidator;
_logger = logger;
_leveData = leveData;
_territoryData = territoryData;
_reloadDataIpc = _pluginInterface.GetIpcProvider<object>("Questionable.ReloadData");
}
@ -150,10 +152,15 @@ internal sealed class QuestRegistry
foreach (var quest in _quests.Values)
{
foreach (var dutyStep in quest.AllSteps().Where(x =>
x.Step.InteractionType is EInteractionType.Duty or EInteractionType.SinglePlayerDuty
&& x.Step.ContentFinderConditionId != null))
x.Step.InteractionType is EInteractionType.Duty or EInteractionType.SinglePlayerDuty))
{
_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(
AutomatonIpc automatonIpc,
AutoDutyIpc autoDutyIpc,
BossModIpc bossModIpc,
TerritoryData territoryData) : SimpleTaskFactory
{
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
? territoryData.GetContentFinderCondition(step.ContentFinderConditionId.Value)?.Name
: 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),
_ => null,
};

View File

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

View File

@ -21,7 +21,8 @@ internal static class WaitAtEnd
IClientState clientState,
ICondition condition,
TerritoryData territoryData,
AutoDutyIpc autoDutyIpc)
AutoDutyIpc autoDutyIpc,
BossModIpc bossModIpc)
: ITaskFactory
{
public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
@ -53,7 +54,7 @@ internal static class WaitAtEnd
return [new WaitNextStepOrSequence()];
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()];
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,
new uint[] { 108, 109 }.Concat(limsaStart.QuestRedoParam.Select(x => x.Quest.RowId))
.Where(x => x != 0)
.Select(x => questData.GetQuestInfo(new QuestId((ushort)(x & 0xFFFF))))
.Select(x => questData.GetQuestInfo(QuestId.FromRowId(x)))
.ToList());
var genreGridania = new Genre(uint.MaxValue - 2, "Starting in Gridania", 1,
new uint[] { 85, 123, 124 }.Concat(gridaniaStart.QuestRedoParam.Select(x => x.Quest.RowId))
.Where(x => x != 0)
.Select(x => questData.GetQuestInfo(new QuestId((ushort)(x & 0xFFFF))))
.Select(x => questData.GetQuestInfo(QuestId.FromRowId(x)))
.ToList());
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))
.Where(x => x != 0)
.Select(x => questData.GetQuestInfo(new QuestId((ushort)(x & 0xFFFF))))
.Select(x => questData.GetQuestInfo(QuestId.FromRowId(x)))
.ToList());
genres.InsertRange(0, [genreLimsa, genreGridania, genreUldah]);
genres.Single(x => x.Id == 1)

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
@ -7,6 +8,7 @@ using Dalamud.Game;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using Lumina.Excel.Sheets;
using Questionable.Model.Questing;
namespace Questionable.Data;
@ -17,6 +19,7 @@ internal sealed class TerritoryData
private readonly ImmutableDictionary<ushort, uint> _dutyTerritories;
private readonly ImmutableDictionary<uint, string> _instanceNames;
private readonly ImmutableDictionary<uint, ContentFinderConditionData> _contentFinderConditions;
private readonly ImmutableDictionary<(ElementId QuestId, byte Index), uint> _questsToCfc;
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)
.Select(x => new ContentFinderConditionData(x, dataManager.Language))
.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);
@ -77,6 +87,18 @@ internal sealed class TerritoryData
[NotNullWhen(true)] out ContentFinderConditionData? 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)
{
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));
}
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(
uint ContentFinderConditionId,
string Name,

View File

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

View File

@ -2,22 +2,33 @@ using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Ipc.Exceptions;
using Dalamud.Plugin.Services;
using Questionable.Data;
using Questionable.Model.Questing;
namespace Questionable.External;
internal sealed class BossModIpc
{
private readonly ICommandManager _commandManager;
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, bool, bool> _createPreset;
private readonly ICallGateSubscriber<string, bool> _setPreset;
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;
_territoryData = territoryData;
_getPreset = pluginInterface.GetIpcSubscriber<string, string?>($"{Name}.Presets.Get");
_createPreset = pluginInterface.GetIpcSubscriber<string, bool, bool>($"{Name}.Presets.Create");
_setPreset = pluginInterface.GetIpcSubscriber<string, bool>($"{Name}.Presets.SetActive");
@ -70,4 +81,21 @@ internal sealed class BossModIpc
_commandManager.ProcessCommand("/vbm cfg ZoneModuleConfig EnableQuestBattles false");
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());
_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.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)
@ -63,6 +63,7 @@ internal sealed class QuestionableIpc : IDisposable
public void Dispose()
{
_startSingleQuest.UnregisterFunc();
_startQuest.UnregisterFunc();
_getCurrentlyActiveEventQuests.UnregisterFunc();
_getCurrentQuestId.UnregisterFunc();

View File

@ -7,6 +7,7 @@ using Lumina.Excel.Sheets;
using Questionable.Model.Questing;
using ExcelQuest = Lumina.Excel.Sheets.Quest;
using GrandCompany = FFXIVClientStructs.FFXIV.Client.UI.Agent.GrandCompany;
using QQuestId = Questionable.Model.Questing.QuestId;
namespace Questionable.Model;
@ -14,7 +15,7 @@ internal sealed class QuestInfo : IQuestInfo
{
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
{
@ -41,15 +42,15 @@ internal sealed class QuestInfo : IQuestInfo
PreviousQuests =
new List<PreviousQuestInfo>
{
new(ReplaceOldQuestIds((ushort)(quest.PreviousQuest[0].RowId & 0xFFFF)), quest.Unknown7),
new(ReplaceOldQuestIds((ushort)(quest.PreviousQuest[1].RowId & 0xFFFF))),
new(ReplaceOldQuestIds((ushort)(quest.PreviousQuest[2].RowId & 0xFFFF)))
new(ReplaceOldQuestIds(QQuestId.FromRowId(quest.PreviousQuest[0].RowId)), quest.Unknown7),
new(ReplaceOldQuestIds(QQuestId.FromRowId(quest.PreviousQuest[1].RowId))),
new(ReplaceOldQuestIds(QQuestId.FromRowId(quest.PreviousQuest[2].RowId)))
}
.Where(x => x.QuestId.Value != 0)
.ToImmutableList();
PreviousQuestJoin = (EQuestJoin)quest.PreviousQuestJoin;
QuestLocks = quest.QuestLock
.Select(x => new QuestId((ushort)(x.RowId & 0xFFFFF)))
.Select(x => QQuestId.FromRowId(x.RowId))
.Where(x => x.Value != 0)
.ToImmutableList();
QuestLockJoin = (EQuestJoin)quest.QuestLockJoin;
@ -85,13 +86,13 @@ internal sealed class QuestInfo : IQuestInfo
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,
});
};
}
public ElementId QuestId { get; }

View File

@ -3,6 +3,7 @@ using System.Collections.Immutable;
using LLib.GameData;
using Lumina.Excel.Sheets;
using Questionable.Model.Questing;
using QQuestId = Questionable.Model.Questing.QuestId;
namespace Questionable.Model;
@ -16,7 +17,7 @@ internal sealed class SatisfactionSupplyInfo : IQuestInfo
Level = npc.LevelUnlock;
SortKey = QuestId.Value;
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; }

View File

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

View File

@ -18,4 +18,5 @@ public enum EIssueType
InvalidAethernetShortcut,
InvalidExcelRef,
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)
{
QuestId questId = new QuestId((ushort)(unacceptedQuest.ObjectiveId & 0xFFFF));
QuestId questId = QuestId.FromRowId(unacceptedQuest.ObjectiveId);
if (_quests.All(q => q.QuestId != 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
}
}