Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
- Feature: draft quests via lua data -chika
2 changes: 1 addition & 1 deletion Directory.Build.targets
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup Condition="$(MSBuildProjectName) != 'GatheringPathRenderer'">
<Version>15.306.0.22</Version>
<Version>15.306.0.23</Version>
</PropertyGroup>

<PropertyGroup>
Expand Down
111 changes: 111 additions & 0 deletions Questionable.Tests/AutoGen/GameDataFixture.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Shared game-data handle for the questpath generator tests.
/// <para>
/// These tests read the installed game's sqpack, which CI does not have. When no installation is
/// found <see cref="Available"/> is false and every test in the collection returns early — the
/// generator is only meaningfully testable against real game data, and a mocked
/// <c>Quest</c>/<c>Level</c>/Lua corpus would test the mock rather than the derivation rules.
/// Set <c>QUESTIONABLE_GAME_PATH</c> to point at a game folder explicitly.
/// </para>
/// </summary>
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;

/// <summary>Fixed author, so generated output never depends on the machine's configuration.</summary>
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;
}
}
}
160 changes: 160 additions & 0 deletions Questionable.Tests/AutoGen/QuestPathGenerationAccuracyTest.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// </summary>
public sealed class QuestPathGenerationAccuracyTest(GameDataFixture fixture, ITestOutputHelper output)
: IClassFixture<GameDataFixture>
{
/// <summary>Measured 97.2% over all 4095 hand-authored quests at the time of writing.</summary>
private const double SequenceMatchFloor = 0.95;

/// <summary>Measured 88.5% over the same set.</summary>
private const double QuestMatchFloor = 0.85;

/// <summary>
/// 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.
/// </summary>
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<string> 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<uint> 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<EmbeddedQuest> 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<QuestRoot>(stream);
}
catch (JsonException)
{
return null;
}
}
}
Loading
Loading