From 8fbef822a802baf57870abbe91992975c568b47f Mon Sep 17 00:00:00 2001 From: alydev Date: Sun, 26 Jul 2026 05:22:48 +1000 Subject: [PATCH 1/4] disable formatting verify check --- .github/workflows/pr-test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 448c34545..c5ad01004 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -51,9 +51,9 @@ jobs: - name: Restore run: dotnet restore /p:Configuration=Release --packages .nuget - - name: Verify formatting - run: | - dotnet format Questionable.sln --verify-no-changes --no-restore --severity warn -v diag + #- name: Verify formatting + # run: | + # dotnet format Questionable.sln --verify-no-changes --no-restore --severity warn -v diag - name: Build test projects run: | From 4a166749a195d9f19bbb0c684f8d8915acdf7012 Mon Sep 17 00:00:00 2001 From: dec <2226816+Deckerz@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:36:31 +0100 Subject: [PATCH 2/4] current auto-gen --- Questionable.Tests/AutoGen/GameDataFixture.cs | 111 ++ .../QuestPathGenerationAccuracyTest.cs | 160 +++ .../AutoGen/QuestPathGenerationTest.cs | 213 ++++ Questionable.Tests/Questionable.Tests.csproj | 10 + Questionable.Tests/packages.lock.json | 37 + Questionable.sln | 4 +- .../AutoGen/Analysis/QuestScriptAnalysis.cs | 225 ++++ Questionable/AutoGen/Analysis/QuestSymbols.cs | 131 +++ Questionable/AutoGen/Analysis/SequenceHint.cs | 88 ++ .../Generation/QuestPathAutoGenerator.cs | 1025 +++++++++++++++++ .../Generation/QuestPathGeneratorFactory.cs | 25 + .../AutoGen/Generation/QuestPathResult.cs | 18 + .../AutoGen/Generation/QuestPathWriter.cs | 167 +++ .../AutoGen/Generation/TravelShortcut.cs | 19 + Questionable/AutoGen/Lua/LuaBytecode.cs | 165 +++ Questionable/AutoGen/Lua/LuaInstruction.cs | 21 + Questionable/AutoGen/Lua/LuaOp.cs | 47 + Questionable/AutoGen/Lua/LuaProto.cs | 20 + Questionable/AutoGen/Lua/LuaSymbolWalk.cs | 68 ++ .../AutoGen/Plugin/DraftQuestPathService.cs | 87 ++ Questionable/AutoGen/QuestChain.cs | 65 ++ Questionable/AutoGen/QuestChainResult.cs | 10 + Questionable/AutoGen/QuestGameData.cs | 595 ++++++++++ Questionable/Configuration.cs | 6 + .../Controller/Steps/Common/MountStep.cs | 9 +- Questionable/QuestionablePlugin.cs | 9 + .../ConfigComponents/DebugConfigComponent.cs | 31 +- .../JournalComponents/QuestJournalUtils.cs | 12 +- 28 files changed, 3373 insertions(+), 5 deletions(-) create mode 100644 Questionable.Tests/AutoGen/GameDataFixture.cs create mode 100644 Questionable.Tests/AutoGen/QuestPathGenerationAccuracyTest.cs create mode 100644 Questionable.Tests/AutoGen/QuestPathGenerationTest.cs create mode 100644 Questionable/AutoGen/Analysis/QuestScriptAnalysis.cs create mode 100644 Questionable/AutoGen/Analysis/QuestSymbols.cs create mode 100644 Questionable/AutoGen/Analysis/SequenceHint.cs create mode 100644 Questionable/AutoGen/Generation/QuestPathAutoGenerator.cs create mode 100644 Questionable/AutoGen/Generation/QuestPathGeneratorFactory.cs create mode 100644 Questionable/AutoGen/Generation/QuestPathResult.cs create mode 100644 Questionable/AutoGen/Generation/QuestPathWriter.cs create mode 100644 Questionable/AutoGen/Generation/TravelShortcut.cs create mode 100644 Questionable/AutoGen/Lua/LuaBytecode.cs create mode 100644 Questionable/AutoGen/Lua/LuaInstruction.cs create mode 100644 Questionable/AutoGen/Lua/LuaOp.cs create mode 100644 Questionable/AutoGen/Lua/LuaProto.cs create mode 100644 Questionable/AutoGen/Lua/LuaSymbolWalk.cs create mode 100644 Questionable/AutoGen/Plugin/DraftQuestPathService.cs create mode 100644 Questionable/AutoGen/QuestChain.cs create mode 100644 Questionable/AutoGen/QuestChainResult.cs create mode 100644 Questionable/AutoGen/QuestGameData.cs diff --git a/Questionable.Tests/AutoGen/GameDataFixture.cs b/Questionable.Tests/AutoGen/GameDataFixture.cs new file mode 100644 index 000000000..cf059227b --- /dev/null +++ b/Questionable.Tests/AutoGen/GameDataFixture.cs @@ -0,0 +1,111 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +using System; +using System.IO; +using System.Text.Json; +using Questionable.AutoGen; +using Questionable.AutoGen.Generation; + +namespace Questionable.Tests.AutoGen; + +/// +/// Shared game-data handle for the questpath generator tests. +/// +/// These tests read the installed game's sqpack, which CI does not have. When no installation is +/// found is false and every test in the collection returns early — the +/// generator is only meaningfully testable against real game data, and a mocked +/// Quest/Level/Lua corpus would test the mock rather than the derivation rules. +/// Set QUESTIONABLE_GAME_PATH to point at a game folder explicitly. +/// +/// +public sealed class GameDataFixture : IDisposable +{ + internal const string GamePathEnvVar = "QUESTIONABLE_GAME_PATH"; + + private static readonly string[] CommonPaths = + [ + @"C:\Program Files (x86)\SquareEnix\FINAL FANTASY XIV - A Realm Reborn", + @"C:\Program Files\SquareEnix\FINAL FANTASY XIV - A Realm Reborn", + @"C:\Program Files (x86)\Steam\steamapps\common\FINAL FANTASY XIV Online", + @"C:\Program Files (x86)\Steam\steamapps\common\FINAL FANTASY XIV - A Realm Reborn" + ]; + + public GameDataFixture() + { + string? sqPack = FindSqPackDirectory(); + if (sqPack == null) + return; + + try + { + GameData = new QuestGameData(sqPack); + Factory = new QuestPathGeneratorFactory(GameData); + } + catch (Exception e) when (e is IOException or InvalidDataException or UnauthorizedAccessException) + { + GameData = null; + Factory = null; + } + } + + public QuestGameData? GameData { get; } + public QuestPathGeneratorFactory? Factory { get; } + + public bool Available => GameData != null && Factory != null; + + /// Fixed author, so generated output never depends on the machine's configuration. + public const string Author = "TestBot"; + + public QuestPathAutoGenerator CreateGenerator() => + Factory?.Create(Author) ?? throw new InvalidOperationException("No game data available."); + + public void Dispose() => GameData?.Dispose(); + + private static string? FindSqPackDirectory() + { + // An explicit path is authoritative: if it is set and wrong, fall back to nothing rather than silently + // testing against a different installation than the one asked for. + if (Environment.GetEnvironmentVariable(GamePathEnvVar) is { Length: > 0 } configured) + return IsGameRoot(configured) ? Path.Combine(configured, "game", "sqpack") : null; + + if (TryFromXivLauncher() is { } launcherPath) + return launcherPath; + + foreach (string path in CommonPaths) + { + if (IsGameRoot(path)) + return Path.Combine(path, "game", "sqpack"); + } + + return null; + } + + private static bool IsGameRoot(string path) => + File.Exists(Path.Combine(path, "game", "sqpack", "ffxiv", "0a0000.win32.index")); + + private static string? TryFromXivLauncher() + { + string config = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "XIVLauncher", + "launcherConfigV3.json"); + + if (!File.Exists(config)) + return null; + + try + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(config)); + if (!document.RootElement.TryGetProperty("GamePath", out JsonElement gamePath)) + return null; + + string? root = gamePath.GetString(); + return root != null && IsGameRoot(root) ? Path.Combine(root, "game", "sqpack") : null; + } + catch (Exception e) when (e is JsonException or IOException or UnauthorizedAccessException) + { + return null; + } + } +} diff --git a/Questionable.Tests/AutoGen/QuestPathGenerationAccuracyTest.cs b/Questionable.Tests/AutoGen/QuestPathGenerationAccuracyTest.cs new file mode 100644 index 000000000..47e656440 --- /dev/null +++ b/Questionable.Tests/AutoGen/QuestPathGenerationAccuracyTest.cs @@ -0,0 +1,160 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using Questionable.AutoGen.Generation; +using Questionable.Model.Questing; +using Questionable.Tests.TestData; +using Xunit; +using Xunit.Abstractions; + +namespace Questionable.Tests.AutoGen; + +/// +/// Measures generated paths against the hand-authored ones and fails if agreement drops below the floors +/// below. This is the guard the individual rule tests cannot give: a change that improves one quest while +/// quietly breaking a hundred others shows up here. +/// +/// The floors sit a little under the measured rates so ordinary noise does not fail the build; they are +/// a regression alarm, not a target. Raise them when the generator genuinely improves. +/// +/// +public sealed class QuestPathGenerationAccuracyTest(GameDataFixture fixture, ITestOutputHelper output) + : IClassFixture +{ + /// Measured 97.2% over all 4095 hand-authored quests at the time of writing. + private const double SequenceMatchFloor = 0.95; + + /// Measured 88.5% over the same set. + private const double QuestMatchFloor = 0.85; + + /// + /// Every Nth embedded quest, so the test covers the whole corpus shape without spending minutes on it. + /// The full sweep is the same code with this set to 1. + /// + private const int SampleStride = 20; + + [Fact] + public void GeneratedPathsAgreeWithHandAuthoredOnes() + { + if (!fixture.Available) + { + output.WriteLine( + $"Skipped: no FFXIV installation found (set {GameDataFixture.GamePathEnvVar} to run these)."); + return; + } + + int sequencesCompared = 0, sequencesMatched = 0, questsCompared = 0, questsMatched = 0; + List mismatches = []; + + foreach (EmbeddedQuest embedded in SampledQuests()) + { + if (!TryReadQuestId(embedded, out ushort questId)) + continue; + + QuestRoot? hand = ReadHandAuthored(embedded); + if (hand == null) + continue; + + QuestPathResult? result = fixture.Factory!.GenerateById(questId, GameDataFixture.Author); + if (result == null) + continue; + + QuestRoot generated = result.Root; + questsCompared++; + bool questMatched = true; + + foreach (QuestSequence handSequence in hand.QuestSequence) + { + // The last targeted step of a sequence is the interaction it exists for; earlier ones are + // navigation waypoints a generator has no way to know about. + uint? target = handSequence.Steps.LastOrDefault(x => x.DataId is > 0)?.DataId; + if (target == null) + continue; + + sequencesCompared++; + List generatedTargets = + [ + .. generated.QuestSequence + .Where(x => x.Sequence == handSequence.Sequence) + .SelectMany(x => x.Steps) + .Where(x => x.DataId is > 0) + .Select(x => x.DataId!.Value) + ]; + + if (generatedTargets.Contains(target.Value)) + { + sequencesMatched++; + } + else + { + questMatched = false; + if (mismatches.Count < 20) + { + mismatches.Add( + $"{embedded.ShortName} seq {handSequence.Sequence}: " + + $"want {target.Value}, got [{string.Join(",", generatedTargets)}]"); + } + } + } + + if (questMatched) + questsMatched++; + } + + Assert.True(questsCompared > 0, "No quests were compared; the embedded QuestPaths resources are missing."); + + double sequenceRate = (double)sequencesMatched / Math.Max(sequencesCompared, 1); + double questRate = (double)questsMatched / Math.Max(questsCompared, 1); + + output.WriteLine($"quests : {questsMatched}/{questsCompared} ({questRate:P1})"); + output.WriteLine($"sequences : {sequencesMatched}/{sequencesCompared} ({sequenceRate:P1})"); + foreach (string mismatch in mismatches) + output.WriteLine($" {mismatch}"); + + Assert.True(sequenceRate >= SequenceMatchFloor, + $"Sequence agreement {sequenceRate:P1} fell below the {SequenceMatchFloor:P0} floor " + + $"({sequencesMatched}/{sequencesCompared})."); + Assert.True(questRate >= QuestMatchFloor, + $"Whole-quest agreement {questRate:P1} fell below the {QuestMatchFloor:P0} floor " + + $"({questsMatched}/{questsCompared})."); + } + + private static IEnumerable SampledQuests() + { + Assembly assembly = typeof(EmbeddedQuest).Assembly; + return assembly.GetManifestResourceNames() + .Where(x => x.StartsWith("QuestPaths/", StringComparison.Ordinal) && + x.EndsWith(".json", StringComparison.Ordinal)) + .OrderBy(x => x, StringComparer.Ordinal) + .Where((_, index) => index % SampleStride == 0) + .Select(x => new EmbeddedQuest(x)); + } + + private static bool TryReadQuestId(EmbeddedQuest embedded, out ushort questId) + { + questId = 0; + int underscore = embedded.ShortName.IndexOf('_'); + return underscore > 0 && + ushort.TryParse(embedded.ShortName[..underscore], NumberStyles.None, CultureInfo.InvariantCulture, + out questId); + } + + private static QuestRoot? ReadHandAuthored(EmbeddedQuest embedded) + { + try + { + using System.IO.Stream stream = embedded.OpenStream(); + return JsonSerializer.Deserialize(stream); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/Questionable.Tests/AutoGen/QuestPathGenerationTest.cs b/Questionable.Tests/AutoGen/QuestPathGenerationTest.cs new file mode 100644 index 000000000..3ef3c04ee --- /dev/null +++ b/Questionable.Tests/AutoGen/QuestPathGenerationTest.cs @@ -0,0 +1,213 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text.Json; +using Questionable.AutoGen.Generation; +using Questionable.Model.Questing; +using Xunit; +using Xunit.Abstractions; + +namespace Questionable.Tests.AutoGen; + +/// +/// Pins the derivation rules in against quests whose shape exercises +/// them. Each case here corresponds to a bug that was found by running the generated path in-game, so a +/// regression in any of these rules means someone's quest silently stops working again. +/// +/// Requires an installed game — see ; without one the tests return early. +/// +/// +public sealed class QuestPathGenerationTest(GameDataFixture fixture, ITestOutputHelper output) + : IClassFixture +{ + private static readonly JsonSerializerOptions ReloadOptions = new() { PropertyNameCaseInsensitive = false }; + + /// Generates a quest, or returns null when there is no game data to generate from. + private QuestRoot? Generate(ushort questId) + { + if (!fixture.Available) + { + output.WriteLine( + $"Skipped: no FFXIV installation found (set {GameDataFixture.GamePathEnvVar} to run these)."); + return null; + } + + QuestPathResult? result = fixture.Factory!.GenerateById(questId, GameDataFixture.Author); + Assert.NotNull(result); + return result.Root; + } + + private static QuestSequence SequenceOf(QuestRoot root, byte sequence) => + Assert.Single(root.QuestSequence, x => x.Sequence == sequence); + + [Fact] + public void Quest481_UsesTheLiceCombRatherThanTalkingToSheep() + { + // The script never declares IsEventItemUsable; only the journal's "Use the lice comb on the shaggy + // sheep" says so, which is what the todo-text fallback exists for. + if (Generate(481) is not { } root) + return; + + QuestSequence sequence = SequenceOf(root, 2); + Assert.NotEmpty(sequence.Steps); + Assert.All(sequence.Steps, step => + { + Assert.Equal(EInteractionType.UseItem, step.InteractionType); + Assert.Equal(2000437u, step.ItemId); + }); + } + + [Fact] + public void Quest1914_PerformsTheEmoteTheJournalAsksFor() + { + // "/dance for Aanu Vanu" - the emote appears in neither QuestParams nor any Lua function. + if (Generate(1914) is not { } root) + return; + + foreach (byte sequenceNumber in new byte[] { 1, 2 }) + { + QuestStep step = Assert.Single(SequenceOf(root, sequenceNumber).Steps); + Assert.Equal(EInteractionType.Emote, step.InteractionType); + Assert.Equal(EEmote.Dance, step.Emote); + } + } + + [Fact] + public void Quest1915_UsesTheSaddleAtTheSequenceThatConsumesIt() + { + // The saddle is held across sequences 1 and 2: picked up at 1, used at 2. Using it starts the fight, + // so the combat step is AfterItemUse rather than AfterInteraction. + if (Generate(1915) is not { } root) + return; + + QuestStep pickUp = Assert.Single(SequenceOf(root, 1).Steps); + Assert.Equal(EInteractionType.Interact, pickUp.InteractionType); + + QuestStep use = Assert.Single(SequenceOf(root, 2).Steps); + Assert.Equal(EInteractionType.Combat, use.InteractionType); + Assert.Equal(EEnemySpawnType.AfterItemUse, use.EnemySpawnType); + Assert.Equal(2001761u, use.ItemId); + } + + [Fact] + public void Quest1916_DoesNotInteractWithTheQuestGiverBeforeAHunt() + { + // The script lists an actor alongside the enemies only because they stay targetable; the fight is the + // objective and the combat step anchors to them, so there is no interaction step in front of it. + if (Generate(1916) is not { } root) + return; + + QuestStep step = Assert.Single(SequenceOf(root, 3).Steps); + Assert.Equal(EInteractionType.Combat, step.InteractionType); + } + + [Fact] + public void Quest1919_KeepsFightingUntilTheKeyDrops() + { + // Without a drop condition the fight ends after a single kill. How many are needed comes from the + // event item's stack size, not the todo quantity. + if (Generate(1919) is not { } root) + return; + + QuestStep fight = Assert.Single(SequenceOf(root, 1).Steps); + Assert.Equal(EInteractionType.Combat, fight.InteractionType); + Assert.NotEmpty(fight.ComplexCombatData); + Assert.All(fight.ComplexCombatData, data => + { + Assert.Equal(2001672u, data.RewardItemId); + Assert.Equal(1, data.RewardItemCount); + }); + + QuestStep use = Assert.Single(SequenceOf(root, 3).Steps); + Assert.Equal(EInteractionType.UseItem, use.InteractionType); + Assert.Equal(2001672u, use.ItemId); + } + + [Fact] + public void Quest1741_VisitsEveryNpcOfAMultiTargetSequence() + { + // "Speak with all three": the todo entries are bare map ranges, so each actor has to be matched to an + // area by position. Taking only the first actor leaves the path stuck on an NPC already spoken to. + if (Generate(1741) is not { } root) + return; + + List targets = [.. SequenceOf(root, 1).Steps.Select(x => x.DataId ?? 0)]; + Assert.Equal([1012057u, 1012056u, 1012053u], targets); + } + + [Fact] + public void Quest480_PlacesActorsTheLevelSheetHasNoRowFor() + { + // The three bolting dodos are quest-only actors: the Level sheet never mentions them and the journal + // only gives a search area with a 103y radius. Standing at the middle of that circle leaves the item + // out of range, so the positions have to come from the zone's layer data instead. + if (Generate(480) is not { } root) + return; + + Vector3[] dodos = + [ + new(574.77f, 87.50f, -34.42f), + new(565.46f, 84.45f, -115.97f), + new(478.68f, 81.16f, -38.10f) + ]; + + List steps = SequenceOf(root, 2).Steps; + Assert.Equal(3, steps.Count); + Assert.Equal([1002661u, 1002687u, 1002688u], steps.Select(x => x.DataId ?? 0)); + Assert.All(steps.Zip(dodos), pair => + { + Assert.Equal(EInteractionType.UseItem, pair.First.InteractionType); + Assert.NotNull(pair.First.Position); + Assert.True(Vector3.Distance(pair.First.Position!.Value, pair.Second) < 1f, + $"{pair.First.DataId} is at {pair.First.Position}, expected {pair.Second}"); + }); + } + + [Fact] + public void Quest1709_FindsEveryEnemyOfAMultiFightSequence() + { + // Four interact-then-fight pairs across four zones; applying combat only to the primary actor found + // half of them. + if (Generate(1709) is not { } root) + return; + + HashSet enemies = + [ + .. root.QuestSequence + .SelectMany(x => x.Steps) + .SelectMany(x => x.KillEnemyDataIds.Concat(x.ComplexCombatData.Select(y => y.DataId))) + ]; + + Assert.Superset(new HashSet { 9487u, 9488u, 9489u, 9490u }, enemies); + } + + [Fact] + public void GeneratedPathsAreLoadableByThePlugin() + { + // Round-trips the writer's output back through the model the plugin loads paths with, which catches + // serialization changes that would produce files the registry cannot read. + if (!fixture.Available) + { + output.WriteLine( + $"Skipped: no FFXIV installation found (set {GameDataFixture.GamePathEnvVar} to run these)."); + return; + } + + foreach (ushort questId in new ushort[] { 481, 1200, 1709, 1741, 1914, 1915, 1916, 1919 }) + { + QuestPathResult? result = fixture.Factory!.GenerateById(questId, GameDataFixture.Author); + Assert.NotNull(result); + + string json = QuestPathWriter.Serialize(result); + QuestRoot? reloaded = JsonSerializer.Deserialize(json, ReloadOptions); + + Assert.NotNull(reloaded); + Assert.NotEmpty(reloaded.QuestSequence); + Assert.Contains(reloaded.QuestSequence, x => x.Sequence == 0); + Assert.Contains(reloaded.QuestSequence, x => x.Sequence == 255); + } + } +} diff --git a/Questionable.Tests/Questionable.Tests.csproj b/Questionable.Tests/Questionable.Tests.csproj index a67c406ab..9ca342f20 100644 --- a/Questionable.Tests/Questionable.Tests.csproj +++ b/Questionable.Tests/Questionable.Tests.csproj @@ -27,6 +27,16 @@ + + + + + + $([System.String]::new('%(RelativeDir)').Substring(3).Replace('\','/'))%(FileName)%(Extension) diff --git a/Questionable.Tests/packages.lock.json b/Questionable.Tests/packages.lock.json index b699922e0..cd0c5362e 100644 --- a/Questionable.Tests/packages.lock.json +++ b/Questionable.Tests/packages.lock.json @@ -8,6 +8,25 @@ "resolved": "6.0.4", "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" }, + "Lumina": { + "type": "Direct", + "requested": "[7.6.1, )", + "resolved": "7.6.1", + "contentHash": "c3WhtkGaYwy6Bf2OFCOa5W/bGRlexDCLFALy9hBpIp7IQqPmAzmqQO9pg0cDQUvN+JelON06JkrJ0mK9PP40Ew==", + "dependencies": { + "BCnEncoder.Net": "2.2.1", + "Microsoft.Extensions.ObjectPool": "10.0.0" + } + }, + "Lumina.Excel": { + "type": "Direct", + "requested": "[7.5.0, )", + "resolved": "7.5.0", + "contentHash": "yrwLCgUoslSrrYaeK2Z0UAcD1gUMv4e3/UObIBNiJG6IJZhO00XzzSFZWxZNiYyhAvzza5UUDyQ6xz4Kz2kscw==", + "dependencies": { + "Lumina": "7.6.0" + } + }, "Meziantou.Analyzer": { "type": "Direct", "requested": "[3.0.122, )", @@ -50,6 +69,14 @@ "resolved": "3.1.3", "contentHash": "go7e81n/UI3LeNqoJIJ3thkS4JfJtiQnDbAxLh09JkJqoHthnfbLS5p68s4/Bm12B9umkoYSB5SaDr68hZNleg==" }, + "BCnEncoder.Net": { + "type": "Transitive", + "resolved": "2.2.1", + "contentHash": "tI5+/OQo0kciLqWrViRjpOH+IL3FjexYnoWZajiGV41g/EM9CGbWsxsPzBDmpoxNkrV9uox/EtIhCIi9chBSFw==", + "dependencies": { + "CommunityToolkit.HighPerformance": "8.4.0" + } + }, "Castle.Core": { "type": "Transitive", "resolved": "5.1.1", @@ -58,6 +85,11 @@ "System.Diagnostics.EventLog": "6.0.0" } }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.4.0", + "contentHash": "flxspiBs0G/0GMp7IK2J2ijV9bTG6hEwFc/z6ekHqB6nwRJ4Ry2yLdx+TkbCUYFCl4XhABkAwomeKbT6zM2Zlg==" + }, "Dalamud.Extensions.MicrosoftLogging": { "type": "Transitive", "resolved": "4.0.1", @@ -149,6 +181,11 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" } }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "bpeCq0IYmVLACyEUMzFIOQX+zZUElG1t+nu1lSxthe7B+1oNYking7b91305+jNB6iwojp9fqTY9O+Nh7ULQxg==" + }, "Microsoft.Extensions.Options": { "type": "Transitive", "resolved": "8.0.0", diff --git a/Questionable.sln b/Questionable.sln index 1839e024c..1b03ad63b 100644 --- a/Questionable.sln +++ b/Questionable.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.10.35013.160 +# Visual Studio Version 18 +VisualStudioVersion = 18.8.12021.73 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Questionable", "Questionable\Questionable.csproj", "{C91EEF13-A1AC-4A40-B695-DD4E378E5989}" EndProject diff --git a/Questionable/AutoGen/Analysis/QuestScriptAnalysis.cs b/Questionable/AutoGen/Analysis/QuestScriptAnalysis.cs new file mode 100644 index 000000000..6180a65a3 --- /dev/null +++ b/Questionable/AutoGen/Analysis/QuestScriptAnalysis.cs @@ -0,0 +1,225 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +using Questionable.AutoGen.Lua; + +namespace Questionable.AutoGen.Analysis; + +/// +/// Recovers sequence structure from a parsed quest script. +/// +/// Quest scripts are template-generated, so the helper functions have a predictable shape: +/// IsAcceptEvent gates which actors are targetable per sequence, GetNpcTradeItems and +/// getNpcTradeItemInfo pair a sequence with the actor and item involved in a handover, and the +/// OnScene* bodies mention QuestOffer/QuestAccepted or +/// QuestCompleted/QuestReward for the accept and turn-in scenes. +/// +/// +/// The pairing is positional: within one function the script tests GetQuestSequence() == SEQ_n +/// and then references the actor for that branch, so references between one SEQ_ symbol and the +/// next are attributed to that sequence. Because that over-collects on branchy functions, every +/// function votes and only the best-supported actors for a sequence are kept. This is a heuristic over +/// the constant stream, not an evaluation of the bytecode. +/// +/// +public sealed class QuestScriptAnalysis +{ + // Function names are compared case-insensitively: the same helper is spelled getNpcTradeItemInfo in some + // scripts and GetNpcTradeItemInfo in others. + private static readonly string[] SequenceMappingFunctions = + [ + "IsAcceptEvent", + "IsAnnounce", + "GetNpcTradeItems", + "getNpcTradeItemInfo", + "GetTodoArgs", + "GetEventItems", + "IsEventItemUsable" + ]; + + private static readonly string[] TradeFunctions = ["GetNpcTradeItems", "getNpcTradeItemInfo"]; + + private readonly Dictionary> _targetVotes = []; + private readonly Dictionary _hints = []; + + private QuestScriptAnalysis() + { + } + + public IReadOnlyDictionary Hints => _hints; + + /// Scene function names that offer or accept the quest. + public IReadOnlyList AcceptScenes => _acceptScenes; + + /// Scene function names that complete the quest or hand out rewards. + public IReadOnlyList CompleteScenes => _completeScenes; + + public bool HasScript { get; private init; } + + /// + /// Whether the script declares IsEventItemUsable. Quests that hand their item to an NPC do not; + /// quests where you use the item on something do. + /// + public bool UsesEventItems { get; private set; } + + private readonly List _acceptScenes = []; + private readonly List _completeScenes = []; + + public static QuestScriptAnalysis Empty() => new() { HasScript = false }; + + public static QuestScriptAnalysis Analyze(LuaProto root, QuestSymbols symbols) + { + QuestScriptAnalysis analysis = new() { HasScript = true }; + + foreach (LuaProto function in EnumerateNamedFunctions(root)) + { + string name = function.Name!; + + // A script that can use event items declares this; without it, items are handed over, not used. + bool isUsableItems = string.Equals(name, "IsEventItemUsable", StringComparison.OrdinalIgnoreCase); + if (isUsableItems) + analysis.UsesEventItems = true; + + if (SequenceMappingFunctions.Contains(name, StringComparer.OrdinalIgnoreCase)) + { + analysis.MapSequences(function, symbols, + isTrade: TradeFunctions.Contains(name, StringComparer.OrdinalIgnoreCase), + isHeldItems: string.Equals(name, "GetEventItems", StringComparison.OrdinalIgnoreCase), + isUsableItems: isUsableItems); + } + + if (!name.StartsWith("OnScene", StringComparison.Ordinal)) + continue; + + HashSet called = [.. LuaSymbolWalk.Deep(function)]; + if (called.Contains("QuestOffer") || called.Contains("QuestAccepted")) + analysis._acceptScenes.Add(name); + if (called.Contains("QuestCompleted") || called.Contains("QuestReward")) + analysis._completeScenes.Add(name); + } + + analysis.KeepBestSupportedTargets(); + return analysis; + } + + private void MapSequences(LuaProto function, QuestSymbols symbols, bool isTrade, bool isHeldItems, + bool isUsableItems) + { + byte? current = null; + bool tookActor = false; + bool tookItem = false; + string? lastActor = null; + + foreach (string symbol in LuaSymbolWalk.Deep(function)) + { + if (symbols.SequenceValue(symbol) is { } sequence) + { + current = sequence; + tookActor = false; + tookItem = false; + lastActor = null; + continue; + } + + if (current is not { } activeSequence) + continue; + + // The script lists an enemy straight after whatever interaction spawns it: "EOBJECT0 ENEMY0". + if (QuestSymbols.IsEnemySymbol(symbol) && symbols.EnemyLevelRow(symbol) is > 0) + { + HintFor(activeSequence).AddEnemy(lastActor, symbol); + continue; + } + + // Only the first actor of a branch is that sequence's primary target; the ones after it are usually + // actors carried over from earlier sequences that happen to still be targetable. They are kept as + // candidates anyway, because a "speak with all three" sequence really does need all of them. + if (symbols.DataId(symbol) is > 0) + { + lastActor = symbol; + HintFor(activeSequence).AddCandidate(symbol); + + if (!tookActor) + { + tookActor = true; + Vote(activeSequence, symbol); + if (isTrade) + HintFor(activeSequence).IsNpcTrade = true; + } + } + else if (isTrade && !tookItem && IsItemSymbol(symbol) && symbols.Value(symbol) is > 0) + { + tookItem = true; + SequenceHint hint = HintFor(activeSequence); + hint.AddTradeItem(symbol); + hint.IsNpcTrade = true; + } + else if (isHeldItems && IsItemSymbol(symbol) && symbols.Value(symbol) is > 0) + { + HintFor(activeSequence).AddHeldItem(symbol); + } + else if (isUsableItems && IsItemSymbol(symbol) && symbols.Value(symbol) is > 0) + { + HintFor(activeSequence).AddUsableItem(symbol); + } + } + } + + private void Vote(byte sequence, string symbol) + { + if (!_targetVotes.TryGetValue(sequence, out Dictionary? votes)) + { + votes = new Dictionary(StringComparer.Ordinal); + _targetVotes[sequence] = votes; + } + + votes[symbol] = votes.GetValueOrDefault(symbol) + 1; + } + + /// Keeps only the actors the most functions agree on for each sequence. + private void KeepBestSupportedTargets() + { + foreach ((byte sequence, Dictionary votes) in _targetVotes) + { + if (votes.Count == 0) + continue; + + int best = votes.Values.Max(); + SequenceHint hint = HintFor(sequence); + foreach (string symbol in votes.Where(x => x.Value == best).Select(x => x.Key).Order(StringComparer.Ordinal)) + hint.AddTarget(symbol); + } + } + + private static bool IsItemSymbol(string symbol) => + symbol.StartsWith("ITEM", StringComparison.Ordinal) || + symbol.StartsWith("EVENTITEM", StringComparison.Ordinal); + + private SequenceHint HintFor(byte sequence) + { + if (!_hints.TryGetValue(sequence, out SequenceHint? hint)) + { + hint = new SequenceHint(sequence); + _hints[sequence] = hint; + } + + return hint; + } + + /// Every named function in the chunk, including the ones nested inside the script table. + private static IEnumerable EnumerateNamedFunctions(LuaProto root) + { + Queue queue = new(); + queue.Enqueue(root); + + while (queue.Count > 0) + { + LuaProto current = queue.Dequeue(); + if (current.Name != null) + yield return current; + + foreach (LuaProto nested in current.Protos) + queue.Enqueue(nested); + } + } +} diff --git a/Questionable/AutoGen/Analysis/QuestSymbols.cs b/Questionable/AutoGen/Analysis/QuestSymbols.cs new file mode 100644 index 000000000..71a2d24a7 --- /dev/null +++ b/Questionable/AutoGen/Analysis/QuestSymbols.cs @@ -0,0 +1,131 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +using Lumina.Excel.Sheets; +using Quest = Lumina.Excel.Sheets.Quest; + +namespace Questionable.AutoGen.Analysis; + +/// +/// The Quest.QuestParams array is the symbol table the quest's Lua script is compiled against: +/// every ACTOR0, SEQ_3, LOC_ACTOR1 or EVENTITEM0 the script names resolves +/// to a row id through this table. +/// +public sealed class QuestSymbols +{ + private readonly Dictionary _values; + + private QuestSymbols(Dictionary values) => _values = values; + + public static QuestSymbols From(Quest quest) + { + Dictionary values = new(StringComparer.Ordinal); + foreach (var param in quest.QuestParams) + { + string instruction = param.ScriptInstruction.ExtractText(); + if (instruction.Length > 0) + values[instruction] = param.ScriptArg; + } + + return new QuestSymbols(values); + } + + public IReadOnlyDictionary All => _values; + + public uint? Value(string symbol) => _values.TryGetValue(symbol, out uint value) ? value : null; + + /// Data id behind an ACTOR/EOBJECT symbol name. + public uint? DataId(string symbol) => + symbol.StartsWith("ACTOR", StringComparison.Ordinal) || symbol.StartsWith("EOBJECT", StringComparison.Ordinal) + ? Value(symbol) + : null; + + /// + /// Sequence byte a SEQ_* symbol stands for. These are runtime globals rather than + /// QuestParams entries, so the number comes from the symbol name: SEQ_3 is sequence 3 and + /// SEQ_FINISH is the terminal sequence 255. + /// + public byte? SequenceValue(string symbol) + { + if (!symbol.StartsWith("SEQ_", StringComparison.Ordinal)) + return null; + + string suffix = symbol["SEQ_".Length..]; + if (string.Equals(suffix, "FINISH", StringComparison.Ordinal)) + return 255; + + if (byte.TryParse(suffix, out byte sequence)) + return sequence; + + // Some scripts alias a sequence through the symbol table instead of naming it inline. + return Value(symbol) is { } value && value <= 255 ? (byte)value : null; + } + + /// + /// Level row id behind an ENEMY* symbol. Unlike actors, these resolve to a Level row whose + /// Object is the BNpcBase id the plugin matches enemies on. + /// + public uint? EnemyLevelRow(string symbol) => + symbol.StartsWith("ENEMY", StringComparison.Ordinal) ? Value(symbol) : null; + + public static bool IsEnemySymbol(string symbol) => symbol.StartsWith("ENEMY", StringComparison.Ordinal); + + /// + /// The quest's single event item, when it has exactly one. "Kill things until three of these drop" + /// objectives point the fight at it; more than one item and there is no telling which the drop is. + /// + public uint? SoleEventItem() + { + uint? only = null; + foreach ((string symbol, uint value) in _values) + { + if (value == 0 || + (!symbol.StartsWith("ITEM", StringComparison.Ordinal) && + !symbol.StartsWith("EVENTITEM", StringComparison.Ordinal))) + { + continue; + } + + if (only != null && only != value) + return null; + + only = value; + } + + return only; + } + + /// Level row id backing a LOC_ACTOR*/LOC_POS_*/LOC_EOBJ* symbol. + public uint? LevelRow(string symbol) => + symbol.StartsWith("LOC_", StringComparison.Ordinal) ? Value(symbol) : null; + + /// The LOC_ symbols that describe where an ACTOR/EOBJECT symbol stands. + public IEnumerable LevelRowsFor(string actorSymbol) + { + string suffix = actorSymbol switch + { + _ when actorSymbol.StartsWith("ACTOR", StringComparison.Ordinal) => actorSymbol["ACTOR".Length..], + _ when actorSymbol.StartsWith("EOBJECT", StringComparison.Ordinal) => actorSymbol["EOBJECT".Length..], + _ => string.Empty + }; + + if (suffix.Length == 0) + yield break; + + bool isObject = actorSymbol.StartsWith("EOBJECT", StringComparison.Ordinal); + string[] candidates = isObject + ? [$"LOC_EOBJ{suffix}", $"LOC_POS_EOBJ{suffix}", $"LOC_EOBJECT{suffix}"] + : [$"LOC_ACTOR{suffix}", $"LOC_POS_ACTOR{suffix}"]; + + foreach (string candidate in candidates) + { + if (Value(candidate) is { } value and > 0) + yield return value; + } + } + + public IEnumerable DutySymbols => + _values.Keys.Where(x => + x.StartsWith("QUESTBATTLE", StringComparison.Ordinal) || + x.StartsWith("INSTANCEDUNGEON", StringComparison.Ordinal)); +} diff --git a/Questionable/AutoGen/Analysis/SequenceHint.cs b/Questionable/AutoGen/Analysis/SequenceHint.cs new file mode 100644 index 000000000..799efc524 --- /dev/null +++ b/Questionable/AutoGen/Analysis/SequenceHint.cs @@ -0,0 +1,88 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +namespace Questionable.AutoGen.Analysis; + +/// What the quest's Lua script says happens at a given sequence. +public sealed class SequenceHint(byte sequence) +{ + private readonly List _targetSymbols = []; + private readonly List _candidateSymbols = []; + private readonly List _tradeItemSymbols = []; + + public byte Sequence { get; } = sequence; + + /// Symbol names (ACTOR2, EOBJECT0) the script associates with this sequence. + public IReadOnlyList TargetSymbols => _targetSymbols; + + /// + /// Every actor the script lists for this sequence, in the order it lists them — not just the primary + /// one. A "speak with all three" sequence needs the rest of them. + /// + public IReadOnlyList CandidateSymbols => _candidateSymbols; + + /// ITEM*/EVENTITEM* symbols handed over at this sequence. + public IReadOnlyList TradeItemSymbols => _tradeItemSymbols; + + /// + /// Event items the player is holding during this sequence, from GetEventItems. Combined with a + /// script that has IsEventItemUsable, these are items to be used on the sequence's target. + /// + public IReadOnlyList HeldItemSymbols => _heldItemSymbols; + + /// + /// Event items IsEventItemUsable permits at this sequence — that is, the sequence the item is + /// actually used in, as opposed to the ones it is merely carried through. + /// + public IReadOnlyList UsableItemSymbols => _usableItemSymbols; + + private readonly List _heldItemSymbols = []; + private readonly List _usableItemSymbols = []; + + public void AddHeldItem(string symbol) + { + if (!_heldItemSymbols.Contains(symbol, StringComparer.Ordinal)) + _heldItemSymbols.Add(symbol); + } + + public void AddUsableItem(string symbol) + { + if (!_usableItemSymbols.Contains(symbol, StringComparer.Ordinal)) + _usableItemSymbols.Add(symbol); + } + + /// Set when the script trades an item to the NPC rather than just talking to them. + public bool IsNpcTrade { get; set; } + + /// + /// Enemies the sequence fights, paired with the actor whose interaction spawns them. Actor is + /// null when the script names an enemy with no preceding actor, i.e. the fight starts on its own. + /// + public IReadOnlyList<(string? Actor, string Enemy)> EnemyPairs => _enemyPairs; + + private readonly List<(string? Actor, string Enemy)> _enemyPairs = []; + + public void AddEnemy(string? actor, string enemy) + { + if (!_enemyPairs.Contains((actor, enemy))) + _enemyPairs.Add((actor, enemy)); + } + + public void AddTarget(string symbol) + { + if (!_targetSymbols.Contains(symbol, StringComparer.Ordinal)) + _targetSymbols.Add(symbol); + } + + public void AddCandidate(string symbol) + { + if (!_candidateSymbols.Contains(symbol, StringComparer.Ordinal)) + _candidateSymbols.Add(symbol); + } + + public void AddTradeItem(string symbol) + { + if (!_tradeItemSymbols.Contains(symbol, StringComparer.Ordinal)) + _tradeItemSymbols.Add(symbol); + } +} diff --git a/Questionable/AutoGen/Generation/QuestPathAutoGenerator.cs b/Questionable/AutoGen/Generation/QuestPathAutoGenerator.cs new file mode 100644 index 000000000..f2ca049cf --- /dev/null +++ b/Questionable/AutoGen/Generation/QuestPathAutoGenerator.cs @@ -0,0 +1,1025 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +using System.Globalization; +using System.Numerics; +using Lumina.Excel.Sheets; +using Quest = Lumina.Excel.Sheets.Quest; +using Questionable.AutoGen.Analysis; +using Questionable.AutoGen.Lua; +using Questionable.Controller.Steps.Common; +using Questionable.Model.Common; +using Questionable.Model.Questing; + +namespace Questionable.AutoGen.Generation; + +/// +/// Produces a first-draft questpath for a quest by combining the Quest sheet +/// (issuer, todo locations, script symbol table) with what the quest's compiled Lua script says about +/// which actor belongs to which sequence. +/// +/// The output is a starting point for a human path author, not a finished path: positions come from +/// Level rows rather than from walking the route, and anything the sheets and script cannot +/// express (dialogue choices, duty specifics, quest-variable gating, whether flying is unlocked yet) +/// is left out. Every step carries a $ dev comment recording where it was derived from. +/// +/// +public sealed class QuestPathAutoGenerator(QuestGameData gameData, string author) +{ + private readonly Dictionary _provenance = new(ReferenceEqualityComparer.Instance); + + /// + /// ENEMY* symbols the script never mentions in a sequence branch. Hunt objectives ("kill ten + /// little ladybugs") often work this way: the enemy is declared but the Lua has nothing to say about it. + /// Kept so the first sequence that only has a search area to offer can claim them. + /// + private readonly List _unattributedEnemies = []; + + public QuestPathResult Generate(Quest quest) + { + _provenance.Clear(); + + QuestSymbols symbols = QuestSymbols.From(quest); + LuaProto? script = gameData.LoadScript(quest); + QuestScriptAnalysis analysis = script != null + ? QuestScriptAnalysis.Analyze(script, symbols) + : QuestScriptAnalysis.Empty(); + + List notes = []; + if (!analysis.HasScript) + notes.Add("No Lua script found; sequence targets come from the Quest sheet alone."); + + HashSet attributed = + [ + .. analysis.Hints.Values.SelectMany(x => x.EnemyPairs).Select(x => x.Enemy) + ]; + _unattributedEnemies.Clear(); + _unattributedEnemies.AddRange(symbols.All.Keys + .Where(QuestSymbols.IsEnemySymbol) + .Where(x => !attributed.Contains(x)) + .Order(StringComparer.Ordinal)); + + List sequences = [BuildAcceptSequence(quest)]; + foreach (byte sequence in CollectSequences(quest, symbols, analysis)) + sequences.Add(BuildSequence(quest, symbols, analysis, sequence)); + sequences.Add(BuildCompleteSequence(quest, symbols, analysis)); + ApplyEventItemUses(quest, symbols, analysis, sequences); + + if (ApplyTravel(sequences)) + { + notes.Add("Some zones had no reachable aetheryte or aethernet shard; add those shortcuts by hand."); + } + + int emptySequences = sequences.Count(x => x.Steps.Count == 0); + if (emptySequences > 0) + notes.Add($"{emptySequences} sequence(s) had no derivable target and were written without steps."); + + QuestRoot root = new() + { + Author = [author], + QuestSequence = sequences + }; + + return new QuestPathResult(quest, root, notes, new Dictionary(_provenance, + ReferenceEqualityComparer.Instance)); + } + + /// Sequences between accept and turn-in, from the script's SEQ_* symbols and the todo list. + private static List CollectSequences(Quest quest, QuestSymbols symbols, QuestScriptAnalysis analysis) + { + SortedSet sequences = []; + + foreach (string symbol in symbols.All.Keys) + { + if (symbols.SequenceValue(symbol) is { } value and > 0 and < 255) + sequences.Add(value); + } + + foreach (var todo in quest.TodoParams) + { + if (todo.ToDoCompleteSeq is > 0 and < 255) + sequences.Add(todo.ToDoCompleteSeq); + } + + foreach (byte sequence in analysis.Hints.Keys) + { + if (sequence is > 0 and < 255) + sequences.Add(sequence); + } + + return [.. sequences]; + } + + private QuestSequence BuildAcceptSequence(Quest quest) + { + QuestSequence sequence = new() { Sequence = 0 }; + + Level? level = gameData.FindLevel(quest.IssuerLocation.RowId); + if (level == null) + return sequence; + + QuestStep step = new( + EInteractionType.AcceptQuest, + quest.IssuerStart.RowId != 0 ? quest.IssuerStart.RowId : null, + StepPosition(level.Value), + level.Value.Territory.RowId); + + Note(step, $"auto: issuer {Describe(quest.IssuerStart.RowId)}"); + sequence.Steps.Add(step); + return sequence; + } + + /// + /// Target selection, most trustworthy source first: + /// a todo location that names an object is the journal's own marker for the sequence; failing that the + /// script's actor for the sequence; failing that a bare todo position with nothing to interact with. + /// + private QuestSequence BuildSequence(Quest quest, QuestSymbols symbols, QuestScriptAnalysis analysis, byte number) + { + QuestSequence sequence = new() { Sequence = number }; + + List todoLevels = TodoLevels(quest, number); + List withObject = [.. todoLevels.Where(x => x.Object.RowId != 0)]; + analysis.Hints.TryGetValue(number, out SequenceHint? hint); + + // Only trust the todo objects when they account for the whole list. A sequence that is mostly bare + // search areas with one named NPC among them is pointing at the areas; the NPC is a bystander who + // happens to stand there, and interacting with them is not the objective. + if (withObject.Count > 0 && withObject.Count == todoLevels.Count) + { + // A sequence's todo list can name the same object more than once; interacting with it twice is not + // what that means, so one step per object is enough. + HashSet alreadyStepped = []; + foreach (Level level in withObject) + { + uint dataId = level.Object.RowId; + if (!alreadyStepped.Add(dataId)) + continue; + + QuestStep step = new(EInteractionType.Interact, dataId, StepPosition(level), level.Territory.RowId); + Note(step, $"auto: todo location -> {Describe(dataId)}"); + sequence.Steps.Add(step); + } + } + else if (hint != null && TodoQty(quest, number) > 1 && todoLevels.Count > 1 && + hint.CandidateSymbols.Count > 1) + { + // "Speak with all three": the todo entries are bare map ranges with nothing to interact with, and + // the actor for each one has to be matched to it by position. + AddStepsPerTodoArea(symbols, sequence, hint, todoLevels); + } + + // When the script names enemies with no actor in front of them, the fight is what the sequence is for. + // The quest giver stays targetable throughout and gets listed for that reason alone, so interacting with + // them mid-hunt is a step the player never performs. Any other actor is still worth emitting. + bool objectiveIsCombat = hint != null && hint.EnemyPairs.Any(x => x.Actor == null); + + // With a fight to be had, the sequence's actor is where it happens rather than someone to talk to; the + // combat step below anchors itself to them. Emitting a separate interaction here produces a step the + // player never performs - the NPC just says a line and the objective is still the fight. + if (sequence.Steps.Count == 0 && !objectiveIsCombat && hint is { TargetSymbols.Count: > 0 }) + { + uint? preferredTerritory = todoLevels.Count > 0 ? todoLevels[0].Territory.RowId : null; + Vector3? near = todoLevels.Count > 0 ? Position(todoLevels[0]) : null; + bool ambiguous = hint.TargetSymbols.Count > 1; + + foreach (string symbol in hint.TargetSymbols) + { + if (BuildStepFromSymbol(symbols, symbol, preferredTerritory, ambiguous, near) is { } step) + sequence.Steps.Add(step); + } + } + + // Applied last, and to the sequence as a whole, so every enemy the script names is covered no matter + // which of the branches above produced the steps. + // Emote first: a todo line that spells out "/dance" is more specific evidence than merely carrying an + // item, so it claims the step before the item-use pass looks at what is left. + ApplyEmote(quest, sequence, number); + + if (hint != null) + { + ApplyCombat(symbols, sequence, hint, todoLevels, TodoQty(quest, number)); + AnnotateTrade(symbols, sequence, hint); + } + + // A sequence with only a search area and nothing to talk to is where an unclaimed hunt objective belongs. + if (sequence.Steps.Count == 0 && todoLevels.Count > 0 && AddUnattributedCombat(symbols, sequence, todoLevels, TodoQty(quest, number))) + return sequence; + + // Last resort before giving up on a target: pair the search areas with whatever actors the script + // named for this sequence. The branch above runs this only for the "speak with all three" shape; here + // the constraints are dropped, because a step pointing at the right NPC beats a bare position. + // Not when the sequence is a fight, though - the combat step already anchors itself to the actor, and + // adding an interaction on the same NPC in front of it is a step the player never performs. + if (sequence.Steps.Count == 0 && !objectiveIsCombat && hint != null && todoLevels.Count > 0) + AddStepsPerTodoArea(symbols, sequence, hint, todoLevels); + + if (sequence.Steps.Count == 0) + { + foreach (Level level in todoLevels) + { + QuestStep step = new(EInteractionType.WalkTo, dataId: null, StepPosition(level), level.Territory.RowId); + Note(step, "auto: todo location with no object - check what actually happens here"); + sequence.Steps.Add(step); + } + } + + return sequence; + } + + /// + /// Turns every enemy the script names for this sequence into combat. Each enemy is attached to the step + /// for the actor that spawns it; if no such step exists yet — a sequence like + /// EOBJECT0 ENEMY0 EOBJECT1 ENEMY1 only produces one step on its own — the missing one is added, + /// in script order. Enemies with no actor in front of them become a standalone fight. + /// + private void ApplyCombat( + QuestSymbols symbols, + QuestSequence sequence, + SequenceHint hint, + List todoLevels, + int requiredCount) + { + foreach (IGrouping group in hint.EnemyPairs.GroupBy(x => x.Actor, + StringComparer.Ordinal)) + { + List enemies = []; + Level? spawnLocation = null; + foreach ((string? _, string enemy) in group) + { + (uint id, Level? level) = ResolveEnemy(symbols, enemy); + if (id == 0) + continue; + + if (!enemies.Contains(id)) + enemies.Add(id); + spawnLocation ??= level; + } + + if (enemies.Count == 0) + continue; + + if (group.Key is not { } actorSymbol) + { + // No actor spawns these, but the sequence's actor is still where the fight is - hand-written + // paths anchor the combat step to them rather than adding a step in front of it. + Level? anchorLevel = null; + uint anchorDataId = 0; + if (hint.TargetSymbols.Count > 0 && hint.TargetSymbols[0] is { } anchorSymbol && + symbols.DataId(anchorSymbol) is { } candidate && candidate != 0) + { + anchorLevel = ResolveLevel(symbols, anchorSymbol, candidate, preferredTerritory: null); + if (anchorLevel != null) + anchorDataId = candidate; + } + + AddStandaloneCombat(sequence, enemies, spawnLocation, todoLevels, symbols.SoleEventItem(), + requiredCount, anchorDataId, anchorLevel); + continue; + } + + if (symbols.DataId(actorSymbol) is not { } dataId || dataId == 0) + continue; + + QuestStep? step = sequence.Steps.FirstOrDefault(x => x.DataId == dataId); + if (step == null) + { + step = BuildStepFromSymbol(symbols, actorSymbol, preferredTerritory: null, ambiguous: false); + if (step == null) + continue; + + sequence.Steps.Add(step); + } + + step.InteractionType = EInteractionType.Combat; + step.EnemySpawnType = EEnemySpawnType.AfterInteraction; + + // The schema allows exactly one of the two enemy lists. A step anchored by a standalone fight + // already carries ComplexCombatData, so these enemies join it rather than opening a second list. + if (step.ComplexCombatData.Count > 0) + { + foreach (uint enemy in enemies.Where(x => !step.ComplexCombatData.Exists(y => y.DataId == x))) + step.ComplexCombatData.Add(new ComplexCombatData { DataId = enemy }); + } + else + { + step.KillEnemyDataIds = enemies; + } + + string existing = _provenance.TryGetValue(step, out string? note) ? note : $"auto: lua {actorSymbol}"; + _provenance[step] = $"{existing}; spawns enemies {string.Join(", ", enemies)}"; + } + } + + /// + /// Emits one step per todo area, pairing each with the candidate actor standing closest to it. Keeps the + /// journal's own ordering, which is the order the sequence expects them to be visited in. + /// + /// + /// How far an actor's own position may sit from a search area before the area is treated as the better + /// answer. Zones are large; a placed actor this far from the journal's marker is not the thing marked. + /// + private const float MaximumAreaMatchDistance = 200f; + + private void AddStepsPerTodoArea( + QuestSymbols symbols, + QuestSequence sequence, + SequenceHint hint, + List todoLevels) + { + List<(string Symbol, uint DataId, Level? Level)> actors = []; + foreach (string symbol in hint.CandidateSymbols) + { + if (symbols.DataId(symbol) is not { } dataId || dataId == 0) + continue; + + actors.Add((symbol, dataId, ResolveLevel(symbols, symbol, dataId, preferredTerritory: null))); + } + + if (actors.Count < todoLevels.Count) + return; + + HashSet used = []; + foreach (Level todo in todoLevels) + { + Vector3 area = Position(todo); + (string Symbol, uint DataId, Vector3 Position)? best = null; + float bestDistance = float.MaxValue; + + foreach (var actor in actors) + { + if (used.Contains(actor.DataId)) + continue; + + foreach (Vector3 candidate in PlacementsIn(actor, todo.Territory.RowId)) + { + // An actor standing hundreds of yalms from the marker is not what the marker points at; it + // is placed somewhere else entirely and the area is the better position to use. + float distance = Vector3.Distance(candidate, area); + if (distance >= bestDistance || distance > MaximumAreaMatchDistance) + continue; + + bestDistance = distance; + best = (actor.Symbol, actor.DataId, candidate); + } + } + + if (best is { } match) + { + used.Add(match.DataId); + QuestStep step = new(EInteractionType.Interact, match.DataId, match.Position, + todo.Territory.RowId); + Note(step, $"auto: todo area {bestDistance:F0}y from lua {match.Symbol} -> {Describe(match.DataId)}"); + sequence.Steps.Add(step); + continue; + } + + // Nothing placed nearby: fall back to the next unused actor and let the area stand in for its + // position, which is the best the data offers once neither source knows where the actor is. + if (actors.FirstOrDefault(x => !used.Contains(x.DataId) && x.DataId != 0) is not { DataId: not 0 } spare) + continue; + + used.Add(spare.DataId); + bool placed = spare.Level is not null; + QuestStep fallback = new( + EInteractionType.Interact, + spare.DataId, + placed ? StepPosition(spare.Level!.Value) : StepPosition(todo), + placed ? spare.Level!.Value.Territory.RowId : todo.Territory.RowId); + + Note(fallback, placed + ? $"auto: todo area, nothing nearby, lua {spare.Symbol} -> {Describe(spare.DataId)}" + : $"auto: todo area, lua {spare.Symbol} -> {Describe(spare.DataId)} has no fixed position"); + sequence.Steps.Add(fallback); + } + } + + /// + /// Every spot in the territory an actor could be standing. A Level row is the actor's own + /// placement and settles it; without one - which is the case for most quest-only actors, the bolting + /// dodos of "capture the bolting dodos" among them - the zone's layer data is where the game itself + /// puts them, and an actor laid out once per quest phase gets a spot for each. + /// + private IEnumerable PlacementsIn((string Symbol, uint DataId, Level? Level) actor, uint territoryId) + { + if (actor.Level is { } level) + { + if (level.Territory.RowId == territoryId) + yield return Position(level); + + yield break; + } + + foreach (Vector3 position in gameData.LayerPositions(actor.DataId, territoryId)) + yield return position; + } + + /// + /// The layer placement of an object closest to , or the first one when there is + /// nothing to measure against. An actor laid out once per quest phase has several, and the journal's + /// marker is the only hint as to which phase this sequence is. + /// + private Vector3? NearestPlacement(uint dataId, uint territoryId, Vector3? near) + { + IReadOnlyList placements = gameData.LayerPositions(dataId, territoryId); + if (placements.Count == 0) + return null; + + if (near is not { } target) + return placements[0]; + + return placements.OrderBy(x => Vector3.Distance(x, target)).First(); + } + + /// + /// Turns plain interactions into item uses where the script says the player is carrying an event item + /// and is able to use it. Accepting, turning in and fighting are left alone — you do not use a key on a + /// quest giver or a boar. + /// + private void ApplyEventItemUses( + Quest quest, + QuestSymbols symbols, + QuestScriptAnalysis analysis, + List sequences) + { + Dictionary bySequence = sequences.ToDictionary(x => x.Sequence); + + foreach (string symbol in analysis.Hints.Values.SelectMany(x => x.HeldItemSymbols).Distinct(StringComparer.Ordinal)) + { + if (symbols.Value(symbol) is not { } itemId || itemId == 0) + continue; + + // IsEventItemUsable names the sequence the item may actually be used in. That is exact, so when it + // is present it settles the question - the item is not used anywhere else, even if a later + // sequence still carries it or an earlier one has something interactable. + List usableAt = + [ + .. analysis.Hints.Values + .Where(x => x.UsableItemSymbols.Contains(symbol, StringComparer.Ordinal)) + .OrderByDescending(x => x.Sequence) + ]; + + bool applied = false; + foreach (SequenceHint usable in usableAt) + { + if (bySequence.TryGetValue(usable.Sequence, out QuestSequence? usableSequence) && + UseItemOn(usableSequence, symbol, itemId)) + { + applied = true; + break; + } + } + + // Only when none of those sequences had anything to use it on does the carried-item heuristic get + // a turn; the exact answer is preferred, but silence is worse than a good guess. + if (applied) + continue; + + if (analysis.UsesEventItems) + { + // An item is picked up in one sequence and used in another, and it is held for the whole span + // between. Using it consumes it, so the latest sequence still holding it is where it gets used - + // anything before that is just carrying it. Sequences that ended up with nothing to use it on + // are skipped, which walks the search back to the last one that does. + IEnumerable holders = analysis.Hints.Values + .Where(x => x.HeldItemSymbols.Contains(symbol, StringComparer.Ordinal) && !x.IsNpcTrade) + .OrderByDescending(x => x.Sequence); + + foreach (SequenceHint holder in holders) + { + if (bySequence.TryGetValue(holder.Sequence, out QuestSequence? sequence) && + UseItemOn(sequence, symbol, itemId)) + { + break; + } + } + + continue; + } + + // Some scripts never declare IsEventItemUsable even though the quest plainly uses the item. The + // journal still says so - "Use the lice comb on the shaggy sheep" - so fall back to the todo line + // that tells you to use it. Only an explicit instruction counts: lines that merely name the item + // while handing it over or picking it up are not item uses. + if (SequenceUsingItem(quest, itemId) is { } named && + bySequence.TryGetValue(named, out QuestSequence? namedSequence)) + { + UseItemOn(namedSequence, symbol, itemId); + } + } + } + + /// + /// The sequence whose todo line tells you to use this event item. Requires both the item's name + /// and a "use" instruction, so "Obtain a fresh bunch of gysahl greens" does not count as using them. + /// + private byte? SequenceUsingItem(Quest quest, uint itemId) + { + string name = gameData.EventItemName(itemId); + if (name.Length == 0) + return null; + + IReadOnlyList todoTexts = gameData.TodoTexts(quest); + for (int i = 0; i < quest.TodoParams.Count && i < todoTexts.Count; i++) + { + string text = todoTexts[i]; + if (quest.TodoParams[i].ToDoCompleteSeq is > 0 and < 255 && + text.Contains(name, StringComparison.OrdinalIgnoreCase) && + text.Contains("use ", StringComparison.OrdinalIgnoreCase)) + { + return quest.TodoParams[i].ToDoCompleteSeq; + } + } + + return null; + } + + /// Converts a sequence's steps to use the item, if any of them can be. False when none apply. + private bool UseItemOn(QuestSequence sequence, string symbol, uint itemId) + { + bool applied = false; + + foreach (QuestStep step in sequence.Steps) + { + switch (step.InteractionType) + { + case EInteractionType.Interact: + step.InteractionType = EInteractionType.UseItem; + step.ItemId = itemId; + break; + + // Interacting is not what starts this fight - using the item on the target is. + case EInteractionType.Combat when step.EnemySpawnType == EEnemySpawnType.AfterInteraction: + step.EnemySpawnType = EEnemySpawnType.AfterItemUse; + step.ItemId = itemId; + break; + + default: + continue; + } + + applied = true; + if (_provenance.TryGetValue(step, out string? note)) + _provenance[step] = $"{note}; use item {symbol}={itemId} on it"; + } + + return applied; + } + + /// Records an item handover in the step comments; the step itself stays a plain interaction. + private void AnnotateTrade(QuestSymbols symbols, QuestSequence sequence, SequenceHint hint) + { + if (!hint.IsNpcTrade) + return; + + string items = string.Join(", ", hint.TradeItemSymbols.Select(x => $"{x}={symbols.Value(x)}")); + string suffix = items.Length > 0 ? $"; hands over {items}" : "; npc trade"; + + foreach (QuestStep step in sequence.Steps) + { + if (_provenance.TryGetValue(step, out string? note)) + _provenance[step] = note + suffix; + } + } + + /// + /// An ENEMY* symbol is stored one of two ways: as a Level row placing a spawned enemy in + /// the world, or — for "kill ten of these" objectives — as the enemy id itself, with no location. + /// + private (uint Id, Level? Level) ResolveEnemy(QuestSymbols symbols, string symbol) + { + if (symbols.EnemyLevelRow(symbol) is not { } value || value == 0) + return (0, null); + + // Type 9 is BNpcBase; anything else in the Level sheet is not an enemy placement. + if (gameData.FindLevel(value) is { Type: 9, Object.RowId: > 0 } level) + return (level.Object.RowId, level); + + return (value, null); + } + + /// + /// Claims the enemies the script never tied to a sequence, once, for the first sequence that has a + /// search area and nothing else to do in it. + /// + private bool AddUnattributedCombat(QuestSymbols symbols, QuestSequence sequence, List todoLevels, int requiredCount) + { + if (_unattributedEnemies.Count == 0) + return false; + + List enemies = []; + Level? spawnLocation = null; + foreach (string symbol in _unattributedEnemies) + { + (uint id, Level? level) = ResolveEnemy(symbols, symbol); + if (id == 0) + continue; + + if (!enemies.Contains(id)) + enemies.Add(id); + + spawnLocation ??= level; + } + + if (enemies.Count == 0) + return false; + + _unattributedEnemies.Clear(); + AddStandaloneCombat(sequence, enemies, spawnLocation, todoLevels, symbols.SoleEventItem(), requiredCount); + return sequence.Steps.Count > 0; + } + + /// Combat with no interaction in front of it: go to the area and fight what is there. + private void AddStandaloneCombat( + QuestSequence sequence, + List enemies, + Level? spawnLocation, + List todoLevels, + uint? dropItemId, + int requiredCount, + uint anchorDataId = 0, + Level? anchorLevel = null) + { + // A placed enemy fights where it stands; an id-only enemy is hunted wherever the journal points; and + // failing both, next to whichever actor the sequence revolves around. + Level? location = spawnLocation ?? (todoLevels.Count > 0 ? todoLevels[0] : anchorLevel); + if (location is not { } spot) + return; + + // The anchor may already have a step of its own from an earlier pass. Turning that one into the fight + // keeps the sequence at one step per target instead of interacting with the NPC and then fighting at + // the same spot. + QuestStep? existing = anchorDataId != 0 + ? sequence.Steps.Find(x => x.DataId == anchorDataId) + : null; + + QuestStep step = existing ?? new QuestStep(EInteractionType.Combat, + anchorDataId != 0 ? anchorDataId : null, StepPosition(spot), spot.Territory.RowId); + + step.InteractionType = EInteractionType.Combat; + step.EnemySpawnType = spawnLocation != null + ? EEnemySpawnType.AutoOnEnterArea + : EEnemySpawnType.OverworldEnemies; + + string provenance = spawnLocation != null + ? $"auto: lua enemies spawn here -> {string.Join(", ", enemies)}" + : $"auto: lua overworld enemies -> {string.Join(", ", enemies)}"; + + // "Kill until it drops": the fight has to keep going until the item is in the bag. Without this the + // plugin stops the moment combat ends, i.e. after a single kill. How many are needed comes from the + // event item's stack size, which is the objective's own limit; the todo quantity counts kills, not + // drops, and the two are not always the same. + int needed = dropItemId is { } id ? gameData.EventItemStackSize(id) : 0; + if (needed == 0) + needed = requiredCount; + + if (spawnLocation == null && dropItemId is { } itemId && needed > 0) + { + step.ComplexCombatData = + [ + .. enemies.Select(x => new ComplexCombatData + { + DataId = x, + MinimumKillCount = (uint)needed, + RewardItemId = itemId, + RewardItemCount = needed + }) + ]; + + provenance += $"; until {needed}x item {itemId} drops"; + } + else + { + step.KillEnemyDataIds = enemies; + } + + if (existing != null) + { + // Keep the note that explains where the step came from, and say what it turned into. + _provenance[step] = _provenance.TryGetValue(step, out string? note) + ? $"{note}; {provenance}" + : provenance; + return; + } + + Note(step, provenance); + sequence.Steps.Add(step); + } + + /// + /// Turns an interaction into an emote when the journal asks for one. "/dance for Aanu Vanu" is the only + /// place the game records which emote a quest wants — it is in neither the script nor the sheet columns. + /// + private void ApplyEmote(Quest quest, QuestSequence sequence, byte number) + { + if (sequence.Steps.Count == 0) + return; + + IReadOnlyList todoTexts = gameData.TodoTexts(quest); + if (todoTexts.Count == 0) + return; + + // The TODO_nn number lines up with the position in TodoParams, which is what names the sequence. + for (int i = 0; i < quest.TodoParams.Count && i < todoTexts.Count; i++) + { + if (quest.TodoParams[i].ToDoCompleteSeq != number) + continue; + + if (EmoteIn(todoTexts[i]) is not { } emote) + continue; + + foreach (QuestStep step in sequence.Steps) + { + if (step.InteractionType != EInteractionType.Interact) + continue; + + step.InteractionType = EInteractionType.Emote; + step.Emote = emote; + + if (_provenance.TryGetValue(step, out string? note)) + _provenance[step] = $"{note}; todo says \"{todoTexts[i].Trim()}\""; + } + + return; + } + } + + /// Finds the first text command in a todo line that names an emote. + private EEmote? EmoteIn(string todoText) + { + foreach (string word in todoText.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + if (word.Length < 2 || word[0] != '/') + continue; + + if (gameData.EmoteFromCommand(word.TrimEnd('.', ',', '!', '?')) is { } emote) + return emote; + } + + return null; + } + + /// Whether this is the NPC who hands the quest out or takes it back in. + private static bool IsQuestGiver(Quest quest, uint? dataId) => + dataId is { } id && id != 0 && (id == quest.IssuerStart.RowId || id == quest.TargetEnd.RowId); + + private static int TodoQty(Quest quest, byte number) + { + foreach (var todo in quest.TodoParams) + { + if (todo.ToDoCompleteSeq == number) + return todo.ToDoQty; + } + + return 0; + } + + private QuestStep? BuildStepFromSymbol( + QuestSymbols symbols, + string symbol, + uint? preferredTerritory, + bool ambiguous, + Vector3? near = null) + { + if (symbols.DataId(symbol) is not { } dataId || dataId == 0) + return null; + + Level? level = ResolveLevel(symbols, symbol, dataId, preferredTerritory); + QuestStep step; + string provenance = $"auto: lua {symbol} -> {Describe(dataId)}"; + + if (level != null) + { + step = new QuestStep(EInteractionType.Interact, dataId, StepPosition(level.Value), + level.Value.Territory.RowId); + } + else if (preferredTerritory is { } territory && + NearestPlacement(dataId, territory, near) is { } placement) + { + // No Level row anywhere, but the zone the journal points at lays the actor out; that is a real + // position rather than the guess a bare search area would give. + step = new QuestStep(EInteractionType.Interact, dataId, placement, territory); + provenance += "; positioned from layer data"; + } + else + return null; + + if (ambiguous) + provenance += "; script lists several actors for this sequence, only one is likely correct"; + + Note(step, provenance); + return step; + } + + /// + /// Where an actor actually stands. World Level rows are preferred over the script's LOC_ + /// symbols, which frequently point at cutscene placements rather than the interactable NPC. + /// + private Level? ResolveLevel(QuestSymbols symbols, string symbol, uint dataId, uint? preferredTerritory) + { + List placements = [.. gameData.LevelsForObject(dataId)]; + + if (preferredTerritory is { } territory) + { + Level? match = placements.Cast().FirstOrDefault(x => x!.Value.Territory.RowId == territory); + if (match != null) + return match; + } + + if (placements.Count > 0) + return placements[0]; + + return symbols.LevelRowsFor(symbol).Select(gameData.FindLevel).FirstOrDefault(x => x != null); + } + + private List TodoLevels(Quest quest, byte number) + { + List levels = []; + foreach (var todo in quest.TodoParams) + { + if (todo.ToDoCompleteSeq != number) + continue; + + foreach (var locationRef in todo.ToDoLocation) + { + if (gameData.FindLevel(locationRef.RowId) is { } level) + levels.Add(level); + } + } + + return levels; + } + + private QuestSequence BuildCompleteSequence(Quest quest, QuestSymbols symbols, QuestScriptAnalysis analysis) + { + QuestSequence sequence = new() { Sequence = 255 }; + + uint dataId = quest.TargetEnd.RowId; + Level? level = null; + string provenance = $"auto: turn-in {Describe(dataId)}"; + + // The journal's own turn-in marker, when the quest has one. + Level? todoLevel = TodoLevels(quest, 255) + .Cast() + .FirstOrDefault(x => x!.Value.Object.RowId != 0); + + if (todoLevel != null) + { + level = todoLevel; + dataId = todoLevel.Value.Object.RowId; + provenance = $"auto: todo turn-in -> {Describe(dataId)}"; + } + else if (dataId != 0 && dataId == quest.IssuerStart.RowId) + { + // Turned in to whoever gave it out, so the issuer location is the right placement of several. + level = gameData.FindLevel(quest.IssuerLocation.RowId); + if (level != null) + provenance = $"auto: turn-in back to issuer {Describe(dataId)}"; + } + + if (level == null && dataId != 0) + { + // SEQ_FINISH usually maps to the turn-in actor, which gives its LOC_ symbol as a last resort. + string? symbol = analysis.Hints.TryGetValue(255, out SequenceHint? hint) + ? hint.TargetSymbols.FirstOrDefault(x => symbols.DataId(x) == dataId) + : null; + + level = ResolveLevel(symbols, symbol ?? string.Empty, dataId, preferredTerritory: null); + } + + if (dataId == 0 || level == null) + return sequence; + + QuestStep step = new( + EInteractionType.CompleteQuest, + dataId, + StepPosition(level.Value), + level.Value.Territory.RowId); + + Note(step, provenance); + sequence.Steps.Add(step); + return sequence; + } + + /// + /// Adds travel shortcuts and marks steps in zones that allow flight. + /// + /// Tracking resets at every sequence, because the plugin picks each sequence up from wherever the + /// player happens to be rather than continuing from the previous step. A shortcut that turns out to + /// be unnecessary is skipped at runtime by the distance checks in UseAetheryteShortcut, so + /// emitting one per sequence is safe and matches what hand-written paths do. + /// + /// + private bool ApplyTravel(List sequences) + { + bool needsManualShortcut = false; + QuestStep? previousStep = null; + + foreach (QuestSequence sequence in sequences) + { + uint previousTerritory = 0; + + foreach (QuestStep step in sequence.Steps) + { + if (step.TerritoryId != previousTerritory) + { + previousTerritory = step.TerritoryId; + + TravelShortcut? travel = step.Position is { } position + ? gameData.ResolveTravel(step.TerritoryId, position) + : null; + + if (travel == null) + { + needsManualShortcut = true; + } + else + { + step.AetheryteShortcut = travel.Aetheryte; + step.AethernetShortcut = travel.Aethernet; + + if (travel.Caveat != null) + { + needsManualShortcut = true; + if (_provenance.TryGetValue(step, out string? existing)) + _provenance[step] = $"{existing}; travel: {travel.Caveat}"; + } + } + } + + // Decided after the shortcut, because where the step is travelled from depends on it. + if (gameData.TerritorySupportsFlight(step.TerritoryId) && IsWorthFlying(step, previousStep)) + step.Fly = true; + + previousStep = step; + } + } + + return needsManualShortcut; + } + + private void Note(QuestStep step, string provenance) => _provenance[step] = provenance; + + /// + /// Height added to a position taken from a Level row that places no object. + /// + /// Those rows mark a search area rather than a thing to stand on, and their Y often sits fractionally + /// under the floor. vnavmesh fails outright on a point below the mesh ("got 0 points"), whereas a + /// point above it just projects back down. QuestRegistry.CreateQuestRoot uses 30 for the same + /// reason + /// + /// + private const float AreaHeightPadding = 30f; + + /// Raw coordinates of a Level row, for measuring distances. + /// + /// Below this, flying is not worth the mount-up. Setting Fly forces the plugin to mount whatever + /// the distance (EMountIf.Always in MoveExecutor); leaving it unset hands the decision to + /// MountEvaluator. Shares that evaluator's threshold so the two agree — a step left unflown + /// because it was short is then one the evaluator also declines to mount for. + /// + private const float MinimumFlightDistance = MountStep.MountDistance; + + /// + /// Whether the trip to this step is long enough to fly. The trip starts wherever the step's own travel + /// shortcut lands the player, or at the previous step when it stays in the same zone. When neither is + /// known the distance is unknowable, and flying is left on rather than guessed away. + /// + private bool IsWorthFlying(QuestStep step, QuestStep? previousStep) + { + if (step.Position is not { } destination) + return true; + + Vector2? origin = null; + if (step.AethernetShortcut is { } aethernet) + origin = gameData.AetherytePosition(aethernet.To); + else if (step.AetheryteShortcut is { } aetheryte) + origin = gameData.AetherytePosition(aetheryte); + else if (previousStep is { Position: { } previousPosition } && + previousStep.TerritoryId == step.TerritoryId) + origin = new Vector2(previousPosition.X, previousPosition.Z); + + if (origin is not { } from) + return true; + + return Vector2.Distance(from, new Vector2(destination.X, destination.Z)) >= MinimumFlightDistance; + } + + private static Vector3 Position(Level level) => new(level.X, level.Y, level.Z); + + /// Coordinates to put in a step, lifted clear of the floor when the row places no object. + private static Vector3 StepPosition(Level level) => + level.Object.RowId == 0 + ? new Vector3(level.X, level.Y + AreaHeightPadding, level.Z) + : new Vector3(level.X, level.Y, level.Z); + + private string Describe(uint dataId) + { + if (dataId == 0) + return "0"; + + string name = gameData.ObjectName(dataId); + return name.Length > 0 ? $"{dataId} ({name})" : dataId.ToString(CultureInfo.InvariantCulture); + } +} + diff --git a/Questionable/AutoGen/Generation/QuestPathGeneratorFactory.cs b/Questionable/AutoGen/Generation/QuestPathGeneratorFactory.cs new file mode 100644 index 000000000..a9d7ff305 --- /dev/null +++ b/Questionable/AutoGen/Generation/QuestPathGeneratorFactory.cs @@ -0,0 +1,25 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +namespace Questionable.AutoGen.Generation; + +/// +/// Creates instances on demand. +/// +/// The game-data indexes are expensive to build and shared across generations, while the author name +/// can change between calls (the plugin reads it from the live configuration), so the factory owns the +/// former and takes the latter per call. +/// +/// +public sealed class QuestPathGeneratorFactory(QuestGameData gameData) +{ + public QuestPathAutoGenerator Create(string author) => + new(gameData, string.IsNullOrWhiteSpace(author) ? "Anonymous" : author.Trim()); + + /// + /// Generates from a journal quest id, or returns null when no such quest exists. Callers that + /// only have an id do not need to touch the Excel sheets themselves. + /// + public QuestPathResult? GenerateById(ushort questId, string author) => + gameData.FindByQuestId(questId) is { } quest ? Create(author).Generate(quest) : null; +} diff --git a/Questionable/AutoGen/Generation/QuestPathResult.cs b/Questionable/AutoGen/Generation/QuestPathResult.cs new file mode 100644 index 000000000..1bf12f874 --- /dev/null +++ b/Questionable/AutoGen/Generation/QuestPathResult.cs @@ -0,0 +1,18 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +using Lumina.Excel.Sheets; +using Quest = Lumina.Excel.Sheets.Quest; +using Questionable.Model.Questing; + +namespace Questionable.AutoGen.Generation; + +/// +/// A generated path plus the caveats that came with it. +/// maps each step to the note written into its $ dev comment. +/// +public sealed record QuestPathResult( + Quest Quest, + QuestRoot Root, + IReadOnlyList Notes, + IReadOnlyDictionary Provenance); diff --git a/Questionable/AutoGen/Generation/QuestPathWriter.cs b/Questionable/AutoGen/Generation/QuestPathWriter.cs new file mode 100644 index 000000000..26660074d --- /dev/null +++ b/Questionable/AutoGen/Generation/QuestPathWriter.cs @@ -0,0 +1,167 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +using System.Collections; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Lumina.Excel.Sheets; +using Quest = Lumina.Excel.Sheets.Quest; +using Questionable.Model.Common; +using Questionable.Model.Questing; + +namespace Questionable.AutoGen.Generation; + +/// Serializes a generated path the same way the plugin's own editor does. +public static class QuestPathWriter +{ + private const string SchemaUrl = "https://qstxiv.github.io/schema/quest-v1.json"; + + /// Mirrors Questionable.Utils.JsonOptions.Default, which the checked-in paths are written with. + private static readonly JsonSerializerOptions Options = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + WriteIndented = true, + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + NoEmptyCollectionModifier, + AlwaysSerializeAttributeModifier, + DefaultTrueModifier, + IgnoreWhenDefaultInstanceModifier + } + } + }; + + public static string FileName(Quest quest) => + $"{quest.RowId & 0xFFFF}_{SimplifyName(QuestGameData.QuestName(quest))}.json"; + + /// Same character stripping QuestInfo.SimplifiedName applies before using a name as a filename. + private static string SimplifyName(string name) + { + foreach (char invalid in new[] { '.', '*', '"', '/', '\\', '<', '>', '|', ':', '?' }) + name = name.Replace(invalid.ToString(), string.Empty, StringComparison.Ordinal); + + return name.Trim(); + } + + public static string Serialize(QuestPathResult result) + { + JsonObject serialized = (JsonObject)JsonSerializer.SerializeToNode(result.Root, Options)!; + + JsonObject output = new() { ["$schema"] = SchemaUrl }; + foreach ((string key, JsonNode? value) in serialized) + output[key] = value?.DeepClone(); + + InjectDevComments(output, result); + + return output.ToJsonString(Options); + } + + /// Whether a path for this quest is already on disk, and so should be left alone. + public static bool Exists(Quest quest, string directory) => + File.Exists(Path.Combine(directory, FileName(quest))); + + public static FileInfo Write(QuestPathResult result, string directory) + { + Directory.CreateDirectory(directory); + FileInfo file = new(Path.Combine(directory, FileName(result.Quest))); + File.WriteAllText(file.FullName, Serialize(result) + Environment.NewLine); + return file; + } + + /// + /// Adds the $ dev comment to each step. It is not part of the C# model — only of the JSON schema — + /// so it has to go in after serialization, matched up positionally with the sequences it came from. + /// + private static void InjectDevComments(JsonObject output, QuestPathResult result) + { + if (output["QuestSequence"] is not JsonArray sequences) + return; + + for (int i = 0; i < sequences.Count && i < result.Root.QuestSequence.Count; i++) + { + if (sequences[i] is not JsonObject sequenceNode) + continue; + + QuestSequence sequence = result.Root.QuestSequence[i]; + if (sequenceNode["Steps"] is not JsonArray steps) + continue; + + for (int j = 0; j < steps.Count && j < sequence.Steps.Count; j++) + { + if (steps[j] is JsonObject stepNode && result.Provenance.TryGetValue(sequence.Steps[j], out string? note)) + stepNode["$"] = note; + } + } + } + + private static void NoEmptyCollectionModifier(JsonTypeInfo typeInfo) + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + // Generic list properties (IList<T>) are not assignable to the non-generic ICollection, but + // their values are - so test the declared type loosely and the value strictly. + if (typeof(ICollection).IsAssignableFrom(property.PropertyType) || IsGenericCollection(property.PropertyType)) + property.ShouldSerialize = (_, value) => value is ICollection { Count: > 0 }; + } + } + + private static bool IsGenericCollection(Type type) => + Array.Exists(type.GetInterfaces(), + x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(ICollection<>)); + + private static void AlwaysSerializeAttributeModifier(JsonTypeInfo typeInfo) + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + if (property.AttributeProvider? + .GetCustomAttributes(typeof(AlwaysSerializeAttribute), inherit: false) + .Length > 0) + { + property.ShouldSerialize = (_, _) => true; + } + } + } + + private static void DefaultTrueModifier(JsonTypeInfo typeInfo) + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + if (property.AttributeProvider? + .GetCustomAttributes(typeof(DefaultTrueAttribute), inherit: false) + .Length > 0) + { + property.ShouldSerialize = (_, value) => value is false; + } + } + } + + private static void IgnoreWhenDefaultInstanceModifier(JsonTypeInfo typeInfo) + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + if (property.AttributeProvider? + .GetCustomAttributes(typeof(IgnoreWhenDefaultInstanceAttribute), inherit: false) + .Length == 0) + continue; + + object? defaultInstance; + try + { + defaultInstance = Activator.CreateInstance(property.PropertyType); + } + catch (Exception ex) when (ex is MissingMethodException or MemberAccessException) + { + continue; + } + + string defaultJson = JsonSerializer.Serialize(defaultInstance, Options); + property.ShouldSerialize = (_, value) => JsonSerializer.Serialize(value, Options) != defaultJson; + } + } +} diff --git a/Questionable/AutoGen/Generation/TravelShortcut.cs b/Questionable/AutoGen/Generation/TravelShortcut.cs new file mode 100644 index 000000000..f08f32783 --- /dev/null +++ b/Questionable/AutoGen/Generation/TravelShortcut.cs @@ -0,0 +1,19 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +using Questionable.Model.Common; + +namespace Questionable.AutoGen.Generation; + +/// +/// How to get to a zone: an aetheryte to teleport to, plus an aethernet hop when the destination is a city +/// ward that has no aetheryte of its own. +/// +/// +/// Set when the choice could not be made confidently, e.g. the zone has several aethernet shards but none of +/// them carry a map marker to measure against. The aetheryte is still worth emitting; the hop is left out. +/// +public sealed record TravelShortcut( + EAetheryteLocation Aetheryte, + AethernetShortcut? Aethernet, + string? Caveat = null); diff --git a/Questionable/AutoGen/Lua/LuaBytecode.cs b/Questionable/AutoGen/Lua/LuaBytecode.cs new file mode 100644 index 000000000..1bcec96af --- /dev/null +++ b/Questionable/AutoGen/Lua/LuaBytecode.cs @@ -0,0 +1,165 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +using System.Text; + +namespace Questionable.AutoGen.Lua; + +/// +/// Minimal reader for the Lua 5.1 binary chunks (.luab) that ship in game_script/. +/// Only the parts needed to recover constants, call structure and nested function names are decoded; +/// this is not a decompiler. +/// +public static class LuaBytecode +{ + private static ReadOnlySpan Signature => "Lua"u8; + + public static LuaProto Parse(byte[] data) + { + if (data.Length < 12 || !data.AsSpan(0, 4).SequenceEqual(Signature)) + throw new InvalidDataException("Not a Lua binary chunk."); + if (data[4] != 0x51) + throw new InvalidDataException($"Unsupported Lua bytecode version 0x{data[4]:X2}, expected 0x51."); + if (data[6] != 1) + throw new InvalidDataException("Big-endian Lua chunks are not supported."); + + int sizeOfSizeT = data[8]; + if (sizeOfSizeT is not (4 or 8)) + throw new InvalidDataException($"Unsupported size_t width {sizeOfSizeT}."); + + int position = 12; + LuaProto root = ReadFunction(data, ref position, sizeOfSizeT); + AssignNames(root); + return root; + } + + private static LuaProto ReadFunction(byte[] d, ref int p, int sizeOfSizeT) + { + string? source = ReadString(d, ref p, sizeOfSizeT); + int lineDefined = BitConverter.ToInt32(d, p); + p += 4; // linedefined + p += 4; // lastlinedefined + p += 4; // nups, numparams, is_vararg, maxstacksize + + int codeCount = BitConverter.ToInt32(d, p); + p += 4; + uint[] code = new uint[codeCount]; + for (int i = 0; i < codeCount; i++) + { + code[i] = BitConverter.ToUInt32(d, p); + p += 4; + } + + int constantCount = BitConverter.ToInt32(d, p); + p += 4; + object?[] constants = new object?[constantCount]; + for (int i = 0; i < constantCount; i++) + { + byte type = d[p++]; + switch (type) + { + case 0: + constants[i] = null; + break; + case 1: + constants[i] = d[p] != 0; + p += 1; + break; + case 3: + constants[i] = BitConverter.ToDouble(d, p); + p += 8; + break; + case 4: + constants[i] = ReadString(d, ref p, sizeOfSizeT); + break; + default: + throw new InvalidDataException($"Unknown constant type {type}."); + } + } + + int protoCount = BitConverter.ToInt32(d, p); + p += 4; + LuaProto[] protos = new LuaProto[protoCount]; + for (int i = 0; i < protoCount; i++) + protos[i] = ReadFunction(d, ref p, sizeOfSizeT); + + SkipDebugInfo(d, ref p, sizeOfSizeT); + + return new LuaProto + { + Source = source, + LineDefined = lineDefined, + Code = code, + Constants = constants, + Protos = protos + }; + } + + private static void SkipDebugInfo(byte[] d, ref int p, int sizeOfSizeT) + { + int lineInfo = BitConverter.ToInt32(d, p); + p += 4 + lineInfo * 4; + + int locVars = BitConverter.ToInt32(d, p); + p += 4; + for (int i = 0; i < locVars; i++) + { + ReadString(d, ref p, sizeOfSizeT); + p += 8; // startpc, endpc + } + + int upValues = BitConverter.ToInt32(d, p); + p += 4; + for (int i = 0; i < upValues; i++) + ReadString(d, ref p, sizeOfSizeT); + } + + private static string? ReadString(byte[] d, ref int p, int sizeOfSizeT) + { + ulong length = sizeOfSizeT == 4 ? BitConverter.ToUInt32(d, p) : BitConverter.ToUInt64(d, p); + p += sizeOfSizeT; + if (length == 0) + return null; + + // The stored length includes the trailing NUL. + string value = Encoding.UTF8.GetString(d, p, (int)length - 1); + p += (int)length; + return value; + } + + /// + /// Recovers the name each nested function was stored under. Quest scripts declare everything as + /// function Chunk.OnSceneNNNNN(...), which compiles to CLOSURE followed by SETTABLE/SETGLOBAL. + /// + private static void AssignNames(LuaProto proto) + { + int? pendingProto = null; + foreach (uint instruction in proto.Code) + { + LuaOp op = LuaInstruction.Op(instruction); + switch (op) + { + case LuaOp.Closure: + pendingProto = LuaInstruction.Bx(instruction); + break; + + case LuaOp.SetGlobal when pendingProto is { } index && index < proto.Protos.Count: + proto.Protos[index].Name ??= proto.ConstantString(LuaInstruction.Bx(instruction)); + pendingProto = null; + break; + + case LuaOp.SetTable when pendingProto is { } index && index < proto.Protos.Count: + { + int b = LuaInstruction.B(instruction); + if (LuaInstruction.IsConstant(b)) + proto.Protos[index].Name ??= proto.ConstantString(LuaInstruction.ConstantIndex(b)); + pendingProto = null; + break; + } + } + } + + foreach (LuaProto nested in proto.Protos) + AssignNames(nested); + } +} diff --git a/Questionable/AutoGen/Lua/LuaInstruction.cs b/Questionable/AutoGen/Lua/LuaInstruction.cs new file mode 100644 index 000000000..dbc9a1207 --- /dev/null +++ b/Questionable/AutoGen/Lua/LuaInstruction.cs @@ -0,0 +1,21 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +namespace Questionable.AutoGen.Lua; + +/// Bit layout of a Lua 5.1 instruction: B(9) C(9) A(8) OP(6). +public static class LuaInstruction +{ + private const int ConstantBit = 1 << 8; + + public static LuaOp Op(uint i) => (LuaOp)(i & 0x3F); + public static int A(uint i) => (int)((i >> 6) & 0xFF); + public static int C(uint i) => (int)((i >> 14) & 0x1FF); + public static int B(uint i) => (int)((i >> 23) & 0x1FF); + public static int Bx(uint i) => (int)(i >> 14); + + /// RK values above 255 address the constant table rather than a register. + public static bool IsConstant(int rk) => (rk & ConstantBit) != 0; + + public static int ConstantIndex(int rk) => rk & ~ConstantBit; +} diff --git a/Questionable/AutoGen/Lua/LuaOp.cs b/Questionable/AutoGen/Lua/LuaOp.cs new file mode 100644 index 000000000..a3bda2337 --- /dev/null +++ b/Questionable/AutoGen/Lua/LuaOp.cs @@ -0,0 +1,47 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +namespace Questionable.AutoGen.Lua; + +/// Lua 5.1 opcodes, in the order the reference implementation defines them. +public enum LuaOp +{ + Move = 0, + LoadK = 1, + LoadBool = 2, + LoadNil = 3, + GetUpval = 4, + GetGlobal = 5, + GetTable = 6, + SetGlobal = 7, + SetUpval = 8, + SetTable = 9, + NewTable = 10, + Self = 11, + Add = 12, + Sub = 13, + Mul = 14, + Div = 15, + Mod = 16, + Pow = 17, + Unm = 18, + Not = 19, + Len = 20, + Concat = 21, + Jmp = 22, + Eq = 23, + Lt = 24, + Le = 25, + Test = 26, + TestSet = 27, + Call = 28, + TailCall = 29, + Return = 30, + ForLoop = 31, + ForPrep = 32, + TForLoop = 33, + SetList = 34, + Close = 35, + Closure = 36, + Vararg = 37 +} diff --git a/Questionable/AutoGen/Lua/LuaProto.cs b/Questionable/AutoGen/Lua/LuaProto.cs new file mode 100644 index 000000000..6a277fe46 --- /dev/null +++ b/Questionable/AutoGen/Lua/LuaProto.cs @@ -0,0 +1,20 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +namespace Questionable.AutoGen.Lua; + +/// A single function prototype out of a Lua 5.1 binary chunk. +public sealed class LuaProto +{ + public string? Source { get; init; } + public int LineDefined { get; init; } + public IReadOnlyList Code { get; init; } = []; + public IReadOnlyList Constants { get; init; } = []; + public IReadOnlyList Protos { get; init; } = []; + + /// Name this function was assigned to, if it could be recovered from the enclosing chunk. + public string? Name { get; internal set; } + + public string? ConstantString(int index) => + index >= 0 && index < Constants.Count ? Constants[index] as string : null; +} diff --git a/Questionable/AutoGen/Lua/LuaSymbolWalk.cs b/Questionable/AutoGen/Lua/LuaSymbolWalk.cs new file mode 100644 index 000000000..81695c582 --- /dev/null +++ b/Questionable/AutoGen/Lua/LuaSymbolWalk.cs @@ -0,0 +1,68 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +namespace Questionable.AutoGen.Lua; + +/// +/// Walks a function body in program order and yields every string constant it touches +/// (globals it reads, table keys, comparison operands, call targets). +/// Quest scripts are generated from a template, so the order of these references is +/// meaningful: SEQ_3 followed by ACTOR3 means "at sequence 3, actor 3 is the target". +/// +public static class LuaSymbolWalk +{ + /// Referenced strings of and everything nested inside it, in program order. + public static IReadOnlyList Deep(LuaProto proto) + { + List result = []; + Walk(proto, result); + return result; + } + + private static void Walk(LuaProto proto, List result) + { + foreach (uint instruction in proto.Code) + { + switch (LuaInstruction.Op(instruction)) + { + case LuaOp.LoadK: + case LuaOp.GetGlobal: + case LuaOp.SetGlobal: + Add(result, proto.ConstantString(LuaInstruction.Bx(instruction))); + break; + + case LuaOp.GetTable: + case LuaOp.SetTable: + case LuaOp.Self: + case LuaOp.Eq: + case LuaOp.Lt: + case LuaOp.Le: + case LuaOp.Add: + case LuaOp.Sub: + AddRk(result, proto, LuaInstruction.B(instruction)); + AddRk(result, proto, LuaInstruction.C(instruction)); + break; + + case LuaOp.Closure: + { + int index = LuaInstruction.Bx(instruction); + if (index >= 0 && index < proto.Protos.Count) + Walk(proto.Protos[index], result); + break; + } + } + } + } + + private static void AddRk(List result, LuaProto proto, int rk) + { + if (LuaInstruction.IsConstant(rk)) + Add(result, proto.ConstantString(LuaInstruction.ConstantIndex(rk))); + } + + private static void Add(List result, string? value) + { + if (!string.IsNullOrEmpty(value)) + result.Add(value); + } +} diff --git a/Questionable/AutoGen/Plugin/DraftQuestPathService.cs b/Questionable/AutoGen/Plugin/DraftQuestPathService.cs new file mode 100644 index 000000000..dddb3fa40 --- /dev/null +++ b/Questionable/AutoGen/Plugin/DraftQuestPathService.cs @@ -0,0 +1,87 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +using Questionable.AutoGen.Generation; +using Questionable.Model.Questing; + + +namespace Questionable.AutoGen; + +/// +/// In-game entry point for the questpath auto-generator: turns a journal quest without a path into a draft +/// file in the user's Quests directory and reloads the registry so it is picked up immediately. +/// The heavy lifting lives in . +/// +internal sealed class DraftQuestPathService( + QuestPathGeneratorFactory generatorFactory, + QuestRegistry questRegistry, + Configuration configuration, + IDalamudPluginInterface pluginInterface, + IChatGui chatGui, + ILogger logger) +{ + /// + /// Generation is opt-in via Advanced.AllowPathGeneration, and additionally requires that drafts + /// can actually load: they land in the user quest directory, which the registry only reads under the + /// same conditions as — generating a file that would never load + /// helps nobody. + /// + public bool CanGenerateDrafts => configuration.Advanced.AllowPathGeneration && UserDirectoryIsLoaded; + + /// Whether the registry reads the user Quests directory at all. + public bool UserDirectoryIsLoaded => configuration.Advanced.Debug || pluginInterface.IsDev +#if DEBUG + || true +#endif + ; + + public void GenerateDraft(IQuestInfo questInfo) + { + try + { + if (questInfo.QuestId is not QuestId questId) + { + chatGui.PrintError(_L("Only normal quests can be auto-generated."), + CommandHandler.MessageTag, CommandHandler.TagColor); + return; + } + + QuestPathResult? result = + generatorFactory.GenerateById(questId.Value, configuration.General.DisplayName); + if (result == null) + { + chatGui.PrintError(_LF("Could not find quest {0} in the game data.", questId), + CommandHandler.MessageTag, CommandHandler.TagColor); + return; + } + + // Always the user Quests directory - the same one QuestRegistry.Reload reads as UserDirectory. + // Deliberately NOT QuestRegistry.GetQuestPathsDirectory(): on a dev install with debug enabled + // that resolves to the repository's QuestPaths bundle, and machine drafts don't belong in the + // checked-in data. + string userQuestsDirectory = Path.Combine(pluginInterface.ConfigDirectory.FullName, "Quests"); + FileInfo file = QuestPathWriter.Write(result, userQuestsDirectory); + + chatGui.Print( + _LF("Generated draft path: {0} ({1} sequences). Review it before trusting it.", + file.Name, result.Root.QuestSequence.Count), + CommandHandler.MessageTag, CommandHandler.TagColor); + foreach (string note in result.Notes) + chatGui.Print($"- {note}", CommandHandler.MessageTag, CommandHandler.TagColor); + + chatGui.PrintError( + _L("Experimental: expect wrong targets and stalls. Watch the quest while it runs - do not go AFK."), + CommandHandler.MessageTag, CommandHandler.TagColor); + + questRegistry.Reload(); + } +#pragma warning disable CA1031 // a failed draft should never take the UI down with it + catch (Exception e) + { + logger.LogError(e, "Unable to generate draft quest path for {QuestId}", questInfo.QuestId); + chatGui.PrintError(_L("Unable to generate a draft path, check /xllog for details."), + CommandHandler.MessageTag, CommandHandler.TagColor); + } +#pragma warning restore CA1031 + } +} diff --git a/Questionable/AutoGen/QuestChain.cs b/Questionable/AutoGen/QuestChain.cs new file mode 100644 index 000000000..8261026f2 --- /dev/null +++ b/Questionable/AutoGen/QuestChain.cs @@ -0,0 +1,65 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +using Lumina.Excel.Sheets; +using Quest = Lumina.Excel.Sheets.Quest; + +namespace Questionable.AutoGen; + + +/// +/// Walks the prerequisite graph out from a quest, in both directions: the quests it requires +/// (Quest.PreviousQuest) and the quests that require it. +/// +public static class QuestChain +{ + /// + /// Ceiling on how many paths one --recursive expansion may write. Applied after quests that + /// already have a file are dropped, so a chain that is mostly done still reaches the gaps further along + /// it rather than spending the budget on quests it is going to skip anyway. + /// + public const int GenerationLimit = 200; + + /// + /// Ceiling on how far the graph walk itself will go. Traversal is only sheet lookups, so this can be + /// far more generous than ; it exists to stop the story graph running away. + /// + public const int TraversalLimit = 5000; + + public static QuestChainResult Resolve(QuestGameData gameData, Quest root, int maxDepth, int limit) + { + Dictionary found = new() { [root.RowId] = root }; + Queue<(Quest Quest, int Depth)> queue = new(); + queue.Enqueue((root, 0)); + bool truncated = false; + + while (queue.Count > 0) + { + (Quest current, int depth) = queue.Dequeue(); + if (depth >= maxDepth) + continue; + + foreach (Quest neighbour in gameData.PreviousQuests(current).Concat(gameData.NextQuests(current))) + { + if (found.ContainsKey(neighbour.RowId)) + continue; + + if (found.Count >= limit) + { + truncated = true; + break; + } + + found[neighbour.RowId] = neighbour; + queue.Enqueue((neighbour, depth + 1)); + } + + if (truncated) + break; + } + + return new QuestChainResult( + [.. found.Values.OrderBy(x => x.RowId & 0xFFFF)], + truncated); + } +} diff --git a/Questionable/AutoGen/QuestChainResult.cs b/Questionable/AutoGen/QuestChainResult.cs new file mode 100644 index 000000000..a3ed4fdd2 --- /dev/null +++ b/Questionable/AutoGen/QuestChainResult.cs @@ -0,0 +1,10 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +using Lumina.Excel.Sheets; +using Quest = Lumina.Excel.Sheets.Quest; + +namespace Questionable.AutoGen; + +/// The quests reachable from a starting quest, and whether the walk stopped at the limit. +public sealed record QuestChainResult(IReadOnlyList Quests, bool Truncated); diff --git a/Questionable/AutoGen/QuestGameData.cs b/Questionable/AutoGen/QuestGameData.cs new file mode 100644 index 000000000..3ac4106cb --- /dev/null +++ b/Questionable/AutoGen/QuestGameData.cs @@ -0,0 +1,595 @@ +// Authored with LLM assistance, changes must be reviewed and owned by a human. +// Initial version reviewed and owned by @Deckerz + +using System.Numerics; +using Lumina; +using Lumina.Data; +using Lumina.Data.Files; +using Lumina.Data.Parsing.Layer; +using Lumina.Excel; +using Lumina.Excel.Sheets; +using Quest = Lumina.Excel.Sheets.Quest; +using Questionable.AutoGen.Lua; +using Questionable.Model.Common; +using Questionable.Model.Questing; +using Questionable.AutoGen.Generation; +using Questionable.Model.Questing.Converter; + +namespace Questionable.AutoGen; + +/// Excel and file lookups the generator needs, backed by a local game install. +public sealed class QuestGameData : IDisposable +{ + // SeIconChar.QuestSync / SeIconChar.QuestRepeatable, which the journal prefixes some names with. + private const char QuestSyncIcon = (char)0xE0BE; + private const char QuestRepeatableIcon = (char)0xE0BF; + + private readonly GameData _gameData; + private readonly bool _ownsGameData; + private readonly Lazy> _levelsByObject; + private readonly Lazy> _aetherytes; + private readonly Lazy> _followers; + private readonly Lazy> _emotesByCommand; + + // Layer data is read per territory on first use; a quest only ever touches a handful of zones. + private readonly Dictionary>> _layerPlacements = []; + + // Quest actors live in planevent; planmap and planlive carry a few of the ones tied to map markers. + private static readonly string[] LayerFiles = ["planevent", "planmap", "planlive"]; + + /// Standalone use: opens the given game/sqpack directory and owns the handle. + public QuestGameData(string sqPackDirectory) + : this(new GameData(sqPackDirectory, new LuminaOptions + { + PanicOnSheetChecksumMismatch = false, + DefaultExcelLanguage = Language.English + }), ownsGameData: true) + { + } + + /// + /// In-plugin use: borrows an already-open (Dalamud's, via + /// IDataManager.GameData), which must not be disposed by us. + /// + public QuestGameData(GameData gameData) + : this(gameData, ownsGameData: false) + { + } + + private QuestGameData(GameData gameData, bool ownsGameData) + { + _gameData = gameData; + _ownsGameData = ownsGameData; + + _levelsByObject = new Lazy>(BuildLevelIndex); + _aetherytes = new Lazy>(BuildAetheryteIndex); + _followers = new Lazy>(BuildFollowerIndex); + _emotesByCommand = new Lazy>(BuildEmoteIndex); + } + + public ExcelSheet Quests => _gameData.GetExcelSheet() + ?? throw new InvalidOperationException("Quest sheet unavailable."); + + public string GameVersion => + _gameData.Repositories.TryGetValue("ffxiv", out var repository) ? repository.Version : "unknown"; + + /// Looks a quest up by its in-journal id, the number used in questpath filenames. + public Quest? FindByQuestId(ushort questId) + { + // Quest rows are 0x10000-based; a bare journal id needs the high bits restored. + foreach (Quest quest in Quests) + { + if ((quest.RowId & 0xFFFF) == questId && !quest.Name.IsEmpty) + return quest; + } + + return null; + } + + public void Dispose() + { + if (_ownsGameData) + _gameData.Dispose(); + } + + /// The quests listed as prerequisites of . + public IEnumerable PreviousQuests(Quest quest) + { + foreach (var previous in quest.PreviousQuest) + { + if (previous.RowId != 0 && Quests.GetRowOrDefault(previous.RowId) is { } row && !row.Name.IsEmpty) + yield return row; + } + } + + /// The quests that list as one of their prerequisites. + public IEnumerable NextQuests(Quest quest) + { + foreach (uint rowId in _followers.Value[quest.RowId]) + { + if (Quests.GetRowOrDefault(rowId) is { } row && !row.Name.IsEmpty) + yield return row; + } + } + + /// Reverse of Quest.PreviousQuest, so the chain can be walked forwards as well as back. + private ILookup BuildFollowerIndex() + { + List<(uint Previous, uint Next)> edges = []; + foreach (Quest quest in Quests) + { + if (quest.Name.IsEmpty) + continue; + + foreach (var previous in quest.PreviousQuest) + { + if (previous.RowId != 0) + edges.Add((previous.RowId, quest.RowId)); + } + } + + return edges.ToLookup(x => x.Previous, x => x.Next); + } + + /// Exact (case-insensitive) name matches if there are any, otherwise substring matches. + public IReadOnlyList FindByName(string name) + { + List exact = []; + List partial = []; + + foreach (Quest quest in Quests) + { + string questName = TrimJournalIcons(quest.Name.ExtractText()); + if (questName.Length == 0) + continue; + + if (string.Equals(questName, name, StringComparison.OrdinalIgnoreCase)) + exact.Add(quest); + else if (questName.Contains(name, StringComparison.OrdinalIgnoreCase)) + partial.Add(quest); + } + + return exact.Count > 0 ? exact : partial; + } + + public static string QuestName(Quest quest) => TrimJournalIcons(quest.Name.ExtractText()); + + private static string TrimJournalIcons(string name) => + name.TrimStart(QuestSyncIcon, QuestRepeatableIcon, ' '); + + /// Reads and parses the quest's compiled Lua script, or null when the quest has none. + public LuaProto? LoadScript(Quest quest) + { + string id = quest.Id.ExtractText(); + int underscore = id.LastIndexOf('_'); + if (underscore < 0 || underscore + 4 > id.Length) + return null; + + // game_script/quest//.luab + string folder = id.Substring(underscore + 1, 3); + var file = _gameData.GetFile($"game_script/quest/{folder}/{id}.luab"); + if (file == null) + return null; + + try + { + return LuaBytecode.Parse(file.Data); + } + catch (InvalidDataException) + { + return null; + } + } + + /// Every Level row pointing at the given ENpc/EObj data id. + public IEnumerable LevelsForObject(uint dataId) => _levelsByObject.Value[dataId]; + + /// + /// Every spot the territory's layer data places the given ENpc/EObj, which is where the ones the + /// Level sheet has no row for actually stand. A quest's own actors are laid out per quest + /// (QST_SubSea068_001 and the like), so an id can appear more than once - the caller picks. + /// + public IReadOnlyList LayerPositions(uint dataId, uint territoryId) => + LayerPlacements(territoryId).TryGetValue(dataId, out List? positions) ? positions : []; + + private Dictionary> LayerPlacements(uint territoryId) + { + if (_layerPlacements.TryGetValue(territoryId, out Dictionary>? cached)) + return cached; + + Dictionary> placements = []; + _layerPlacements[territoryId] = placements; + + TerritoryType? territory = _gameData.GetExcelSheet()?.GetRowOrDefault(territoryId); + string bg = territory?.Bg.ExtractText() ?? string.Empty; + int lastSlash = bg.LastIndexOf('/'); + if (lastSlash < 0) + return placements; + + foreach (string name in LayerFiles) + { + LgbFile? lgb; + try + { + lgb = _gameData.GetFile($"bg/{bg[..lastSlash]}/{name}.lgb"); + } + catch (Exception) + { + // Not every zone has every layer file, and a few of the ones that do fail to parse. Layer data + // is a fallback for positions the Level sheet is missing, so losing one file is not fatal. + continue; + } + + if (lgb == null) + continue; + + foreach (LayerCommon.Layer layer in lgb.Layers) + { + foreach (LayerCommon.InstanceObject instance in layer.InstanceObjects) + { + uint baseId = instance.Object switch + { + LayerCommon.ENPCInstanceObject enpc => enpc.ParentData.ParentData.BaseId, + LayerCommon.EventInstanceObject eobj => eobj.ParentData.BaseId, + _ => 0u + }; + + if (baseId == 0) + continue; + + Vector3 position = new(instance.Transform.Translation.X, instance.Transform.Translation.Y, + instance.Transform.Translation.Z); + if (placements.TryGetValue(baseId, out List? existing)) + { + if (!existing.Contains(position)) + existing.Add(position); + } + else + placements[baseId] = [position]; + } + } + } + + return placements; + } + + public Level? FindLevel(uint levelRowId) + { + if (levelRowId == 0) + return null; + + Level? level = _gameData.GetExcelSheet()?.GetRowOrDefault(levelRowId); + return level is { Territory.RowId: > 0 } ? level : null; + } + + /// Whether the zone has aether currents at all, which is the offline proxy for "flying is possible here". + public bool TerritorySupportsFlight(uint territoryId) + { + TerritoryType? territory = _gameData.GetExcelSheet()?.GetRowOrDefault(territoryId); + return territory is { AetherCurrentCompFlgSet.RowId: > 0 }; + } + + public string TerritoryName(uint territoryId) + { + TerritoryType? territory = _gameData.GetExcelSheet()?.GetRowOrDefault(territoryId); + return territory?.PlaceName.ValueNullable?.Name.ExtractText() ?? $"#{territoryId}"; + } + + /// + /// The quest's own todo lines, indexed by their TODO_nn number, which lines up with the + /// TodoParams array. Emote objectives spell the emote out here and nowhere else: + /// "/dance for Aanu Vanu." + /// + public IReadOnlyList TodoTexts(Quest quest) + { + string id = quest.Id.ExtractText(); + int underscore = id.LastIndexOf('_'); + if (underscore < 0 || underscore + 4 > id.Length) + return []; + + ExcelSheet? sheet; + try + { + sheet = _gameData.Excel.GetSheet(name: $"quest/{id.Substring(underscore + 1, 3)}/{id}"); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or NotSupportedException) + { + return []; + } + + if (sheet == null) + return []; + + SortedDictionary byIndex = []; + foreach (RawRow row in sheet) + { + string key = row.ReadStringColumn(0).ExtractText(); + int marker = key.IndexOf("_TODO_", StringComparison.Ordinal); + if (marker < 0 || !int.TryParse(key[(marker + "_TODO_".Length)..], out int index)) + continue; + + byIndex[index] = row.ReadStringColumn(1).ExtractText(); + } + + return byIndex.Count == 0 ? [] : [.. Enumerable.Range(0, byIndex.Keys.Max() + 1) + .Select(x => byIndex.TryGetValue(x, out string? text) ? text : string.Empty)]; + } + + /// Maps a text command such as /dance to the emote it triggers. + public EEmote? EmoteFromCommand(string command) + { + if (_emotesByCommand.Value.TryGetValue(command, out EEmote emote)) + return emote; + + return null; + } + + private Dictionary BuildEmoteIndex() + { + ExcelSheet? emotes = _gameData.GetExcelSheet(); + if (emotes == null) + return []; + + Dictionary byCommand = new(StringComparer.OrdinalIgnoreCase); + foreach (Emote emote in emotes) + { + if (emote.RowId == 0 || !Enum.IsDefined((EEmote)emote.RowId)) + continue; + + if (emote.TextCommand.ValueNullable is not { } textCommand) + continue; + + foreach (var candidate in new[] { textCommand.Command, textCommand.ShortCommand, textCommand.Alias }) + { + string text = candidate.ExtractText(); + if (text.Length > 1) + byCommand[text] = (EEmote)emote.RowId; + } + } + + return byCommand; + } + + /// + /// How many of a quest's event item you are asked to collect. The sheet's stack size is exactly that + /// limit — you cannot hold more than the objective needs — so it doubles as the required count. + /// + /// Singular name of an event item, as the journal's todo lines write it ("lice comb"). + public string EventItemName(uint itemId) => + _gameData.GetExcelSheet()?.GetRowOrDefault(itemId)?.Singular.ExtractText() ?? string.Empty; + + public int EventItemStackSize(uint itemId) => + _gameData.GetExcelSheet()?.GetRowOrDefault(itemId)?.StackSize is { } size and > 0 + ? (int)size + : 0; + + public string ObjectName(uint dataId) + { + if (dataId == 0) + return string.Empty; + + if (dataId >= 2_000_000) + return _gameData.GetExcelSheet()?.GetRowOrDefault(dataId)?.Singular.ExtractText() ?? string.Empty; + + return _gameData.GetExcelSheet()?.GetRowOrDefault(dataId)?.Singular.ExtractText() ?? string.Empty; + } + + /// + /// Works out how to travel to a point: the nearest aetheryte in that zone, or — for city wards that have + /// no aetheryte of their own, like Limsa Lominsa Upper Decks — the zone's nearest aethernet shard plus + /// the hop to reach it from its aethernet group's main aetheryte. + /// + public TravelShortcut? ResolveTravel(uint territoryId, Vector3 position) + { + Vector2 target = new(position.X, position.Z); + + List mainAetherytes = InTerritory(territoryId, main: true); + (AetheryteEntry? aetheryte, bool aetheryteConfident) = Choose(mainAetherytes, target); + if (aetheryte != null) + { + return new TravelShortcut(aetheryte.Value.Location, Aethernet: null, + aetheryteConfident ? null : Ambiguity(aetheryte.Value, mainAetherytes)); + } + + List shards = InTerritory(territoryId, main: false); + (AetheryteEntry? shard, bool shardConfident) = Choose(shards, target); + if (shard == null) + return null; + + // Reach the shard from whichever aetheryte anchors its aethernet group. + AetheryteEntry? hub = _aetherytes.Value + .Cast() + .FirstOrDefault(x => x!.Value.IsMainAetheryte && + x.Value.AethernetGroup != 0 && + x.Value.AethernetGroup == shard.Value.AethernetGroup); + + if (hub is not { } origin || !IsAethernetEndpoint(origin.Location) || + !IsAethernetEndpoint(shard.Value.Location)) + return null; + + // Always emit the hop, even when the shard is a guess: the plugin cannot path between zones, so landing + // at the wrong shard inside the target zone still works out, whereas stopping at the hub strands it. + return new TravelShortcut( + origin.Location, + new AethernetShortcut { From = origin.Location, To = shard.Value.Location }, + shardConfident ? null : Ambiguity(shard.Value, shards)); + } + + /// + /// Picks the entry nearest . Reports low confidence when the choice had to be + /// made between several candidates whose positions are unknown — not every aethernet shard has a map + /// marker (the Idyllshire gates into the Dravanian Hinterlands, for instance), and guessing between two + /// unplaced shards is how you end up at the wrong end of the zone. + /// + private static (AetheryteEntry? Entry, bool Confident) Choose(List candidates, Vector2 target) + { + switch (candidates.Count) + { + case 0: + return (null, false); + case 1: + return (candidates[0], true); + } + + // Some entries are never drawn on a map (airship landings, the Idyllshire gates). Choosing among the + // ones that are placed is fine; it is only a guess when none of them are. + List placed = [.. candidates.Where(x => x.Position != null)]; + if (placed.Count == 0) + return (candidates[0], false); + + AetheryteEntry best = placed[0]; + float bestDistance = Vector2.Distance(best.Position!.Value, target); + foreach (AetheryteEntry entry in placed.Skip(1)) + { + float distance = Vector2.Distance(entry.Position!.Value, target); + if (distance >= bestDistance) + continue; + + bestDistance = distance; + best = entry; + } + + return (best, true); + } + + private static string Ambiguity(AetheryteEntry chosen, List candidates) + { + string others = string.Join(", ", candidates + .Where(x => x.Location != chosen.Location) + .Select(x => x.Location)); + + return $"guessed {chosen.Location} - none of this zone's shards are placed on a map, " + + $"so verify it against {others}"; + } + + /// + /// Where an aetheryte or aethernet shard stands, in world X/Z. Null when it carries no map marker to + /// recover a position from. + /// + public Vector2? AetherytePosition(EAetheryteLocation location) + { + foreach (AetheryteEntry entry in _aetherytes.Value) + { + if (entry.Location == location) + return entry.Position; + } + + return null; + } + + private List InTerritory(uint territoryId, bool main) => + [.. _aetherytes.Value.Where(x => x.TerritoryId == territoryId && x.IsMainAetheryte == main)]; + + /// Both ends of an aethernet hop have to be names the questpath schema knows. + private static bool IsAethernetEndpoint(EAetheryteLocation location) => + AethernetShardConverter.Values.ContainsKey(location); + + private ILookup BuildLevelIndex() + { + ExcelSheet? levels = _gameData.GetExcelSheet(); + if (levels == null) + return Enumerable.Empty().ToLookup(_ => 0u); + + return levels + .Where(x => x.Object.RowId != 0 && x.Territory.RowId != 0) + .ToLookup(x => x.Object.RowId); + } + + private List BuildAetheryteIndex() + { + ExcelSheet? aetherytes = _gameData.GetExcelSheet(); + if (aetherytes == null) + return []; + + Dictionary positions = BuildAetherytePositions(aetherytes); + + List entries = []; + foreach (EAetheryteLocation location in Enum.GetValues()) + { + if (location == EAetheryteLocation.None) + continue; + + Aetheryte? aetheryte = aetherytes.GetRowOrDefault((uint)location); + if (aetheryte is not { Territory.RowId: > 0 }) + continue; + + entries.Add(new AetheryteEntry( + location, + aetheryte.Value.Territory.RowId, + aetheryte.Value.AethernetGroup, + aetheryte.Value.IsAetheryte, + positions.TryGetValue((uint)location, out Vector2 position) ? position : null)); + } + + return entries; + } + + /// + /// Aetheryte world positions, recovered from map markers. + /// + /// The Level rows the Aetheryte sheet points at no longer exist, but MapMarker + /// still carries every aetheryte (DataType 3, keyed by aetheryte row) and every aethernet + /// shard (DataType 4, keyed by the PlaceName in Aetheryte.AethernetName). + /// Marker coordinates are map pixels, so they convert back to world X/Z through the map's size + /// factor and offset — accurate to a couple of units, which is ample for picking the nearest one. + /// + /// + private Dictionary BuildAetherytePositions(ExcelSheet aetherytes) + { + ExcelSheet? maps = _gameData.GetExcelSheet(); + SubrowExcelSheet? markers = _gameData.GetSubrowExcelSheet(); + if (maps == null || markers == null) + return []; + + Dictionary aetheryteByAethernetName = []; + foreach (Aetheryte aetheryte in aetherytes) + { + if (aetheryte.AethernetName.RowId != 0) + aetheryteByAethernetName[aetheryte.AethernetName.RowId] = aetheryte.RowId; + } + + Dictionary positions = []; + foreach (Map map in maps) + { + if (map.MapMarkerRange == 0 || map.SizeFactor == 0) + continue; + + if (!markers.TryGetRow(map.MapMarkerRange, out SubrowCollection group)) + continue; + + foreach (MapMarker marker in group) + { + uint? aetheryteId = marker.DataType switch + { + 3 => marker.DataKey.RowId, + 4 when aetheryteByAethernetName.TryGetValue(marker.DataKey.RowId, out uint id) => id, + _ => null + }; + + if (aetheryteId is not { } id2 || id2 == 0) + continue; + + float scale = map.SizeFactor / 100f; + Vector2 world = new( + (marker.X - 1024f) / scale - map.OffsetX, + (marker.Y - 1024f) / scale - map.OffsetY); + + // Region maps repeat a zone's markers; the map covering the aetheryte's own territory wins. + bool preferred = map.TerritoryType.RowId != 0 && + map.TerritoryType.RowId == aetherytes.GetRowOrDefault(id2)?.Territory.RowId; + + if (preferred || !positions.ContainsKey(id2)) + positions[id2] = world; + } + } + + return positions; + } + + private readonly record struct AetheryteEntry( + EAetheryteLocation Location, + uint TerritoryId, + ushort AethernetGroup, + bool IsMainAetheryte, + Vector2? Position); +} diff --git a/Questionable/Configuration.cs b/Questionable/Configuration.cs index 8e7f745b2..c46a811b9 100644 --- a/Questionable/Configuration.cs +++ b/Questionable/Configuration.cs @@ -303,6 +303,12 @@ internal sealed class AdvancedConfiguration public bool NamazuPreferCraft { get; set; } public bool Debug { get; set; } public bool DebugLocalisation { get; set; } + + /// + /// Gates the experimental questpath auto-generation (Journal Progress right-click). Generated paths + /// are unreviewed machine drafts and must not be run unattended. + /// + public bool AllowPathGeneration { get; set; } public bool AutoRedeemRewardItems { get; set; } public HashSet AutoRedeemItemBlacklist { get; set; } = []; } diff --git a/Questionable/Controller/Steps/Common/MountStep.cs b/Questionable/Controller/Steps/Common/MountStep.cs index 75b09acec..66785762f 100644 --- a/Questionable/Controller/Steps/Common/MountStep.cs +++ b/Questionable/Controller/Steps/Common/MountStep.cs @@ -5,6 +5,12 @@ namespace Questionable.Controller.Steps.Common; internal static class MountStep { + /// + /// Distance to the destination below which mounting is not worth the cast. Applies when a step has not + /// forced the issue with Mount/Fly, i.e. . + /// + public const float MountDistance = 50f; + public enum EMountIf { Always, @@ -63,7 +69,8 @@ public unsafe MountResult EvaluateMountState(MountTask task, bool dryRun, ref Da { Vector3 playerPosition = objectTable[0]?.Position ?? Vector3.Zero; float distance = System.Numerics.Vector3.Distance(playerPosition, task.Position.GetValueOrDefault()); - if (task.TerritoryId == clientState.TerritoryType && distance < 50f && !condition[ConditionFlag.Diving]) + if (task.TerritoryId == clientState.TerritoryType && distance < MountDistance && + !condition[ConditionFlag.Diving]) { logger.Log(logLevel, "Not using mount, as we're close to the target"); logger.LogDebug("Returning 'DontMount'"); diff --git a/Questionable/QuestionablePlugin.cs b/Questionable/QuestionablePlugin.cs index 66d06c999..09d7de4ad 100644 --- a/Questionable/QuestionablePlugin.cs +++ b/Questionable/QuestionablePlugin.cs @@ -1,4 +1,6 @@ using PunishLib; +using Questionable.AutoGen; +using Questionable.AutoGen.Generation; using Questionable.Controller.Steps.Common; using Questionable.Controller.Steps.Fishing; using Questionable.Controller.Steps.Gathering; @@ -150,6 +152,13 @@ private static void AddBasicFunctionsAndData(ServiceCollection serviceCollection serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + + // Questpath auto-generation (Questionable/AutoGen): reads game data through Dalamud's Lumina + // instance, which QuestGameData borrows without disposing. + serviceCollection.AddSingleton(sp => + new QuestGameData(sp.GetRequiredService().GameData)); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); } private static void AddTaskFactories(ServiceCollection serviceCollection) diff --git a/Questionable/Windows/ConfigComponents/DebugConfigComponent.cs b/Questionable/Windows/ConfigComponents/DebugConfigComponent.cs index dd9190f7c..e5c13355c 100644 --- a/Questionable/Windows/ConfigComponents/DebugConfigComponent.cs +++ b/Questionable/Windows/ConfigComponents/DebugConfigComponent.cs @@ -13,7 +13,8 @@ internal sealed class DebugConfigComponent IDalamudPluginInterface pluginInterface, Configuration configuration, PathDataUpdater pathDataUpdater, - IDataManager dataManager) : ConfigComponent(pluginInterface, configuration) + IDataManager dataManager, + AutoGen.DraftQuestPathService draftQuestPathService) : ConfigComponent(pluginInterface, configuration) { private readonly ItemBlacklistSelector _itemBlacklistSelector = new(dataManager); private uint? _itemToRemove; @@ -36,6 +37,34 @@ public override void DrawTab() Save(); } + ImGui.Separator(); + + bool allowPathGeneration = Configuration.Advanced.AllowPathGeneration; + if (ImGui.Checkbox(_L("Allow questpath generation (experimental)"), ref allowPathGeneration)) + { + Configuration.Advanced.AllowPathGeneration = allowPathGeneration; + Save(); + } + + if (allowPathGeneration) + { + using (ImRaii.PushIndent()) + { + ImGui.TextColored(ImGuiColors.DalamudOrange, + _L("Generated paths are unreviewed machine drafts: expect wrong targets, missing steps and stalls.")); + ImGui.TextColored(ImGuiColors.DalamudOrange, + _L("Stay at the keyboard while one is running - never leave it unattended.")); + ImGui.TextUnformatted( + _L("Right-click a quest without a path in the Journal Progress window to generate a draft.")); + + if (!draftQuestPathService.UserDirectoryIsLoaded) + { + ImGui.TextColored(ImGuiColors.DalamudRed, + _L("Generated paths only load in debug mode or on a dev install; without one of those, this option does nothing.")); + } + } + } + if (ImGui.CollapsingHeader(_L("Information"))) { using (ImRaii.PushIndent()) diff --git a/Questionable/Windows/JournalComponents/QuestJournalUtils.cs b/Questionable/Windows/JournalComponents/QuestJournalUtils.cs index 3817b914d..900739ce1 100644 --- a/Questionable/Windows/JournalComponents/QuestJournalUtils.cs +++ b/Questionable/Windows/JournalComponents/QuestJournalUtils.cs @@ -17,7 +17,8 @@ internal sealed class QuestJournalUtils AetheryteData aetheryteData, AetheryteFunctions aetheryteFunctions, MovementController movementController, - IGameGui gameGui) + IGameGui gameGui, + AutoGen.DraftQuestPathService draftQuestPathService) { public void ShowContextMenu(IQuestInfo questInfo, Quest? quest, string label) { @@ -138,6 +139,15 @@ public void ShowContextMenu(IQuestInfo questInfo, Quest? quest, string label) { if (ImGui.MenuItem(_L("Edit quest path"))) (bool success, string filename) = QuestRegistry.OpenEditor(questInfo); + + // Only offered while the quest has no path at all; once the draft is written and the registry + // reloads, the quest is known and the entry disappears on its own. + if (quest == null && draftQuestPathService.CanGenerateDrafts && + ImGui.MenuItem(_L("Generate draft path"))) + { + draftQuestPathService.GenerateDraft(questInfo); + } + if (ImGui.MenuItem(_L("Sim quest"))) questController.SimulateQuest(questInfo, 0, 0); } From b3eaae7afd3b69ddbbc4e4f80c0f91d1ed0a1490 Mon Sep 17 00:00:00 2001 From: alydev Date: Sun, 26 Jul 2026 11:04:42 +1000 Subject: [PATCH 3/4] draft quests via lua data --- CHANGELOG.md | 3 +-- Directory.Build.targets | 2 +- Questionable/Windows/JournalComponents/QuestJournalUtils.cs | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a27d5aac8..ad11285f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1 @@ -- Change: config option for "Stop when completing any of the quests selected below" to enable/disable auto removing items from the list after complete (so future runs of the same quest go back to normal behaviour) -alydev - - this previously always removed the quest from the "stop when complete" list, but is now configurable \ No newline at end of file +- Feature: draft quests via lua data -chika \ No newline at end of file diff --git a/Directory.Build.targets b/Directory.Build.targets index 0eee5fdb7..b29fa9cc7 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,6 +1,6 @@ - 15.306.0.22 + 15.306.0.23 diff --git a/Questionable/Windows/JournalComponents/QuestJournalUtils.cs b/Questionable/Windows/JournalComponents/QuestJournalUtils.cs index 900739ce1..eeee550e4 100644 --- a/Questionable/Windows/JournalComponents/QuestJournalUtils.cs +++ b/Questionable/Windows/JournalComponents/QuestJournalUtils.cs @@ -142,7 +142,7 @@ public void ShowContextMenu(IQuestInfo questInfo, Quest? quest, string label) // Only offered while the quest has no path at all; once the draft is written and the registry // reloads, the quest is known and the entry disappears on its own. - if (quest == null && draftQuestPathService.CanGenerateDrafts && + if (draftQuestPathService.CanGenerateDrafts && ImGui.MenuItem(_L("Generate draft path"))) { draftQuestPathService.GenerateDraft(questInfo); From b7c76e4863b8d4c2a9b03368d2feb6e46e20a036 Mon Sep 17 00:00:00 2001 From: alydev Date: Sun, 26 Jul 2026 11:08:22 +1000 Subject: [PATCH 4/4] Revert pr-test.yml --- .github/workflows/pr-test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index c5ad01004..448c34545 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -51,9 +51,9 @@ jobs: - name: Restore run: dotnet restore /p:Configuration=Release --packages .nuget - #- name: Verify formatting - # run: | - # dotnet format Questionable.sln --verify-no-changes --no-restore --severity warn -v diag + - name: Verify formatting + run: | + dotnet format Questionable.sln --verify-no-changes --no-restore --severity warn -v diag - name: Build test projects run: |