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);
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(),
},
"TerritoryId": 153,
"InteractionType": "SinglePlayerDuty",
+ "SinglePlayerDutyIndex": 1,
"Fly": true
}
]
},\r
"TerritoryId": 154,\r
"InteractionType": "SinglePlayerDuty",\r
+ "SinglePlayerDutyIndex": 1,\r
"AetheryteShortcut": "North Shroud - Fallgourd Float",\r
"Fly": true\r
}\r
"Z": 29.06836\r
},\r
"TerritoryId": 152,\r
- "InteractionType": "SinglePlayerDuty"\r
+ "InteractionType": "SinglePlayerDuty",\r
+ "SinglePlayerDutyIndex": 1\r
}\r
]\r
},\r
},
"TerritoryId": 130,
"InteractionType": "SinglePlayerDuty",
+ "SinglePlayerDutyIndex": 1,
"AetheryteShortcut": "Ul'dah"
}
]
"TerritoryId": 138,
"InteractionType": "SinglePlayerDuty",
"Fly": true,
- "ContentFinderConditionId": 393,
"BossModEnabled": true
}
]
},
"TerritoryId": 401,
"InteractionType": "SinglePlayerDuty",
- "ContentFinderConditionId": 395,
"BossModEnabled": true
}
]
"[Ishgard] The Forgotten Knight",
"[Ishgard] The Tribunal"
],
- "ContentFinderConditionId": 396,
"BossModEnabled": true
}
]
},
"TerritoryId": 145,
"InteractionType": "SinglePlayerDuty",
- "ContentFinderConditionId": 400,
"BossModEnabled": true
}
]
"TerritoryId": 397,
"InteractionType": "SinglePlayerDuty",
"DisableNavmesh": true,
- "ContentFinderConditionId": 397,
"BossModEnabled": true
}
]
},
"TerritoryId": 418,
"InteractionType": "SinglePlayerDuty",
- "ContentFinderConditionId": 398,
"BossModEnabled": true
}
]
"InteractionType": "SinglePlayerDuty",
"Emote": "lookout",
"StopDistance": 0.25,
- "ContentFinderConditionId": 401,
"BossModEnabled": true
}
]
"[Idyllshire] Aetheryte Plaza",
"[Idyllshire] Epilogue Gate (Eastern Hinterlands)"
],
- "ContentFinderConditionId": 422,
"BossModEnabled": true
}
]
},
"TerritoryId": 402,
"InteractionType": "SinglePlayerDuty",
- "ContentFinderConditionId": 399,
"BossModEnabled": true
}
]
"StopDistance": 5,
"TerritoryId": 829,
"InteractionType": "SinglePlayerDuty",
+ "SinglePlayerDutyIndex": 1,
"DialogueChoices": [
{
"Type": "List",
},
"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"
}
}
}
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);
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();
{
"$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",
{
"$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",
{
"$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",
{
"$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": {
{
"$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": {
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();
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;
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 = [];
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;
_jsonSchemaValidator = jsonSchemaValidator;
_logger = logger;
_leveData = leveData;
+ _territoryData = territoryData;
_reloadDataIpc = _pluginInterface.GetIpcProvider<object>("Questionable.ReloadData");
}
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);
}
}
}
internal sealed class Factory(
AutomatonIpc automatonIpc,
AutoDutyIpc autoDutyIpc,
+ BossModIpc bossModIpc,
TerritoryData territoryData) : SimpleTaskFactory
{
public override ITask? CreateTask(Quest quest, QuestSequence sequence, QuestStep step)
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,
};
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;
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();
}
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;
}
}
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;
}
IClientState clientState,
ICondition condition,
TerritoryData territoryData,
- AutoDutyIpc autoDutyIpc)
+ AutoDutyIpc autoDutyIpc,
+ BossModIpc bossModIpc)
: ITaskFactory
{
public IEnumerable<ITask> CreateAllTasks(Quest quest, QuestSequence sequence, QuestStep step)
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:
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)
using System;
+using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using Lumina.Excel.Sheets;
+using Questionable.Model.Questing;
namespace Questionable.Data;
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)
{
.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);
[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)
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,
_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;
_territoryData.TryGetContentFinderCondition(cfcId.Value, out _))
return true;
- return autoDutyEnabled && HasPath(cfcId.Value);
+ return enabledByDefault && HasPath(cfcId.Value);
}
public bool HasPath(uint cfcId)
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");
_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;
+ }
}
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)
public void Dispose()
{
+ _startSingleQuest.UnregisterFunc();
_startQuest.UnregisterFunc();
_getCurrentlyActiveEventQuests.UnregisterFunc();
_getCurrentQuestId.UnregisterFunc();
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;
{
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
{
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;
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; }
using LLib.GameData;
using Lumina.Excel.Sheets;
using Questionable.Model.Questing;
+using QQuestId = Questionable.Model.Questing.QuestId;
namespace Questionable.Model;
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; }
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>());
}
InvalidAethernetShortcut,
InvalidExcelRef,
ClassQuestWithoutAetheryteShortcut,
+ DuplicateSinglePlayerInstance,
}
--- /dev/null
+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}",
+ };
+ }
+ }
+ }
+}
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));
}
--- /dev/null
+{
+ "sdk": {
+ "version": "8.0.0",
+ "rollForward": "latestMinor",
+ "allowPrerelease": false
+ }
+}
\ No newline at end of file