[submodule "LLib"]
path = LLib
url = https://git.carvel.li/liza/LLib.git
+[submodule "vendor/ECommons"]
+ path = vendor/ECommons
+ url = https://github.com/NightmareXIV/ECommons.git
<Project Sdk="Dalamud.NET.Sdk/10.0.0">
+ <ItemGroup>
+ <ProjectReference Include="..\Questionable.Model\Questionable.Model.csproj" />
+ <ProjectReference Include="..\vendor\ECommons\ECommons\ECommons.csproj" />
+ </ItemGroup>
+
<Import Project="..\LLib\LLib.targets"/>
</Project>
--- /dev/null
+{
+ "Name": "GatheringPathRenderer",
+ "Author": "Liza Carvelli",
+ "Punchline": "dev only plugin: Renders gathering location.",
+ "Description": "dev only plugin: Renders gathering location (without ECommons polluting the entire normal project)."
+}
-using Dalamud.Plugin;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Dalamud.Plugin;
+using Dalamud.Plugin.Services;
+using ECommons;
+using ECommons.Schedulers;
+using ECommons.SplatoonAPI;
+using Questionable.Model.Gathering;
namespace GatheringPathRenderer;
public sealed class RendererPlugin : IDalamudPlugin
{
+ private const long OnTerritoryChange = -2;
+ private readonly IDalamudPluginInterface _pluginInterface;
+ private readonly IClientState _clientState;
+ private readonly IPluginLog _pluginLog;
+ private readonly List<(ushort Id, GatheringRoot Root)> _gatheringLocations = [];
+
+ public RendererPlugin(IDalamudPluginInterface pluginInterface, IClientState clientState, IPluginLog pluginLog)
+ {
+ _pluginInterface = pluginInterface;
+ _clientState = clientState;
+ _pluginLog = pluginLog;
+
+ _pluginInterface.GetIpcSubscriber<object>("Questionable.ReloadData")
+ .Subscribe(Reload);
+
+ ECommonsMain.Init(pluginInterface, this, Module.SplatoonAPI);
+ LoadGatheringLocationsFromDirectory();
+
+ _clientState.TerritoryChanged += TerritoryChanged;
+ if (_clientState.IsLoggedIn)
+ TerritoryChanged(_clientState.TerritoryType);
+ }
+
+ private void Reload()
+ {
+ LoadGatheringLocationsFromDirectory();
+ TerritoryChanged(_clientState.TerritoryType);
+ }
+
+ private void LoadGatheringLocationsFromDirectory()
+ {
+ _gatheringLocations.Clear();
+
+ DirectoryInfo? solutionDirectory = _pluginInterface.AssemblyLocation.Directory?.Parent?.Parent?.Parent;
+ if (solutionDirectory != null)
+ {
+ DirectoryInfo pathProjectDirectory =
+ new DirectoryInfo(Path.Combine(solutionDirectory.FullName, "GatheringPaths"));
+ if (pathProjectDirectory.Exists)
+ {
+ try
+ {
+ LoadFromDirectory(
+ new DirectoryInfo(Path.Combine(pathProjectDirectory.FullName, "2.x - A Realm Reborn")));
+ LoadFromDirectory(
+ new DirectoryInfo(Path.Combine(pathProjectDirectory.FullName, "3.x - Heavensward")));
+ LoadFromDirectory(
+ new DirectoryInfo(Path.Combine(pathProjectDirectory.FullName, "4.x - Stormblood")));
+ LoadFromDirectory(
+ new DirectoryInfo(Path.Combine(pathProjectDirectory.FullName, "5.x - Shadowbringers")));
+ LoadFromDirectory(
+ new DirectoryInfo(Path.Combine(pathProjectDirectory.FullName, "6.x - Endwalker")));
+ LoadFromDirectory(
+ new DirectoryInfo(Path.Combine(pathProjectDirectory.FullName, "7.x - Dawntrail")));
+
+ _pluginLog.Information(
+ $"Loaded {_gatheringLocations.Count} gathering root locations from project directory");
+ }
+ catch (Exception e)
+ {
+ _pluginLog.Error(e, "Failed to load quests from project directory");
+ }
+ }
+ else
+ _pluginLog.Warning($"Project directory {pathProjectDirectory} does not exist");
+ }
+ else
+ _pluginLog.Warning($"Solution directory {solutionDirectory} does not exist");
+ }
+
+ private void LoadFromDirectory(DirectoryInfo directory)
+ {
+ if (!directory.Exists)
+ return;
+
+ _pluginLog.Information($"Loading locations from {directory}");
+ foreach (FileInfo fileInfo in directory.GetFiles("*.json"))
+ {
+ try
+ {
+ using FileStream stream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read);
+ LoadLocationFromStream(fileInfo.Name, stream);
+ }
+ catch (Exception e)
+ {
+ throw new InvalidDataException($"Unable to load file {fileInfo.FullName}", e);
+ }
+ }
+
+ foreach (DirectoryInfo childDirectory in directory.GetDirectories())
+ LoadFromDirectory(childDirectory);
+ }
+
+ private void LoadLocationFromStream(string fileName, Stream stream)
+ {
+ var locationNode = JsonNode.Parse(stream)!;
+ GatheringRoot root = locationNode.Deserialize<GatheringRoot>()!;
+ _gatheringLocations.Add((ushort.Parse(fileName.Split('_')[0]), root));
+ }
+
+ private void TerritoryChanged(ushort territoryId)
+ {
+ Splatoon.RemoveDynamicElements("GatheringPathRenderer");
+
+ var elements = _gatheringLocations
+ .Where(x => x.Root.TerritoryId == territoryId)
+ .SelectMany(v =>
+ v.Root.Groups.SelectMany(group =>
+ group.Nodes.SelectMany(node => node.Locations
+ .SelectMany(x =>
+ new List<Element>
+ {
+ new Element(x.IsCone()
+ ? ElementType.ConeAtFixedCoordinates
+ : ElementType.CircleAtFixedCoordinates)
+ {
+ refX = x.Position.X,
+ refY = x.Position.Z,
+ refZ = x.Position.Y,
+ Filled = true,
+ radius = x.MinimumDistance,
+ Donut = x.MaximumDistance - x.MinimumDistance,
+ color = 0x2020FF80,
+ Enabled = true,
+ coneAngleMin = x.IsCone() ? (int)x.MinimumAngle.GetValueOrDefault() : 0,
+ coneAngleMax = x.IsCone() ? (int)x.MaximumAngle.GetValueOrDefault() : 0
+ },
+ new Element(ElementType.CircleAtFixedCoordinates)
+ {
+ refX = x.Position.X,
+ refY = x.Position.Z,
+ refZ = x.Position.Y,
+ color = 0x00000000,
+ Enabled = true,
+ overlayText = $"{v.Id} // {node.DataId} / {node.Locations.IndexOf(x)}"
+ }
+ }))))
+ .ToList();
+
+ if (elements.Count == 0)
+ {
+ _pluginLog.Information("No new elements to render.");
+ return;
+ }
+
+ _ = new TickScheduler(delegate
+ {
+ try
+ {
+ Splatoon.AddDynamicElements("GatheringPathRenderer",
+ elements.ToArray(),
+ new[] { OnTerritoryChange });
+ _pluginLog.Information($"Created {elements.Count} splatoon elements.");
+ }
+ catch (Exception e)
+ {
+ _pluginLog.Error(e, "Unable to create splatoon layer");
+ }
+ });
+ }
+
public void Dispose()
{
+ _clientState.TerritoryChanged -= TerritoryChanged;
+
+ Splatoon.RemoveDynamicElements("GatheringPathRenderer");
+ ECommonsMain.Dispose();
+ _pluginInterface.GetIpcSubscriber<object>("Questionable.ReloadData")
+ .Unsubscribe(Reload);
}
}
"Microsoft.Build.Tasks.Git": "1.1.1",
"Microsoft.SourceLink.Common": "1.1.1"
}
+ },
+ "System.Text.Encodings.Web": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ=="
+ },
+ "System.Text.Json": {
+ "type": "Transitive",
+ "resolved": "8.0.4",
+ "contentHash": "bAkhgDJ88XTsqczoxEMliSrpijKZHhbJQldhAmObj/RbrN3sU5dcokuXmWJWsdQAhiMJ9bTayWsL1C9fbbCRhw==",
+ "dependencies": {
+ "System.Text.Encodings.Web": "8.0.0"
+ }
+ },
+ "ecommons": {
+ "type": "Project"
+ },
+ "gatheringpaths": {
+ "type": "Project",
+ "dependencies": {
+ "Questionable.Model": "[1.0.0, )"
+ }
+ },
+ "questionable.model": {
+ "type": "Project",
+ "dependencies": {
+ "System.Text.Json": "[8.0.4, )"
+ }
}
}
}
"Author": "liza",
"TerritoryId": 957,
"AetheryteShortcut": "Thavnair - Great Work",
- "Nodes": [
+ "Groups": [
{
- "DataId": 33918,
- "Position": {
- "X": -582.5132,
- "Y": 40.54578,
- "Z": -426.0171
- }
+ "Nodes": [
+ {
+ "DataId": 33918,
+ "Locations": [
+ {
+ "Position": {
+ "X": -582.5132,
+ "Y": 40.54578,
+ "Z": -426.0171
+ },
+ "MinimumAngle": -50,
+ "MaximumAngle": 90
+ }
+ ]
+ },
+ {
+ "DataId": 33919,
+ "Locations": [
+ {
+ "Position": {
+ "X": -578.2101,
+ "Y": 41.27147,
+ "Z": -447.6376
+ },
+ "MinimumAngle": 130,
+ "MaximumAngle": 220
+ },
+ {
+ "Position": {
+ "X": -546.2882,
+ "Y": 44.52267,
+ "Z": -435.8184
+ },
+ "MinimumAngle": 200,
+ "MaximumAngle": 360
+ }
+ ]
+ }
+ ]
},
{
- "DataId": 33919,
- "Position": {
- "X": -578.2101,
- "Y": 41.27147,
- "Z": -447.6376
- }
+ "Nodes": [
+ {
+ "DataId": 33920,
+ "Locations": [
+ {
+ "Position": {
+ "X": -488.2276,
+ "Y": 34.71221,
+ "Z": -359.6945
+ },
+ "MinimumAngle": 20,
+ "MaximumAngle": 128,
+ "MinimumDistance": 1.3
+ }
+ ]
+ },
+ {
+ "DataId": 33921,
+ "Locations": [
+ {
+ "Position": {
+ "X": -498.8687,
+ "Y": 31.08014,
+ "Z": -351.9397
+ },
+ "MinimumAngle": 40,
+ "MaximumAngle": 190
+ },
+ {
+ "Position": {
+ "X": -490.7759,
+ "Y": 28.70215,
+ "Z": -344.4114
+ },
+ "MinimumAngle": -110,
+ "MaximumAngle": 60
+ },
+ {
+ "Position": {
+ "X": -494.1286,
+ "Y": 32.89971,
+ "Z": -355.0208
+ },
+ "MinimumAngle": 80,
+ "MaximumAngle": 230
+ }
+ ]
+ }
+ ]
},
{
- "DataId": 33920,
- "Position": {
- "X": -488.2276,
- "Y": 34.71221,
- "Z": -359.6945
- }
- },
- {
- "DataId": 33921,
- "Position": {
- "X": -498.8687,
- "Y": 31.08014,
- "Z": -351.9397
- }
- },
- {
- "DataId": 33922,
- "Position": {
- "X": -304.0609,
- "Y": 68.76999,
- "Z": -479.1875
- }
- },
- {
- "DataId": 33923,
- "Position": {
- "X": -293.6989,
- "Y": 68.77935,
- "Z": -484.2256
- }
+ "Nodes": [
+ {
+ "DataId": 33922,
+ "Locations": [
+ {
+ "Position": {
+ "X": -304.0609,
+ "Y": 68.76999,
+ "Z": -479.1875
+ },
+ "MinimumAngle": -110,
+ "MaximumAngle": 70
+ }
+ ]
+ },
+ {
+ "DataId": 33923,
+ "Locations": [
+ {
+ "Position": {
+ "X": -293.6989,
+ "Y": 68.77935,
+ "Z": -484.2256
+ },
+ "MinimumAngle": -30,
+ "MaximumAngle": 110
+ },
+ {
+ "Position": {
+ "X": -295.0806,
+ "Y": 69.12621,
+ "Z": -498.1898
+ },
+ "MinimumAngle": 10,
+ "MaximumAngle": 200
+ },
+ {
+ "Position": {
+ "X": -281.4858,
+ "Y": 67.64153,
+ "Z": -477.6673
+ },
+ "MinimumAngle": -90,
+ "MaximumAngle": 60
+ }
+ ]
+ }
+ ]
}
]
}
if (_locations == null)
{
_locations = [];
-#if RELEASE
LoadLocations();
-#endif
}
- return _locations ?? throw new InvalidOperationException("quest data is not initialized");
+ return _locations ?? throw new InvalidOperationException("location data is not initialized");
}
public static Stream QuestSchema =>
<AdditionalFiles Include="..\Questionable.Model\common-schema.json" />
</ItemGroup>
- <ItemGroup Condition="'$(Configuration)' == 'Release'">
+ <ItemGroup>
<None Remove="2.x - A Realm Reborn" />
<None Remove="3.x - Heavensward" />
<None Remove="4.x - Stormblood" />
"AetheryteShortcut": {
"$ref": "https://git.carvel.li/liza/Questionable/raw/branch/master/Questionable.Model/common-schema.json#/$defs/Aetheryte"
},
- "Nodes": {
+ "Groups": {
"type": "array",
"items": {
"type": "object",
"properties": {
- "DataId": {
- "type": "number",
- "minimum": 30000,
- "maximum": 50000
- },
- "Position": {
- "$ref": "#/$defs/Vector3"
- },
- "MinimumAngle": {
- "type": "number",
- "minimum": -360,
- "maximum": 360
- },
- "MaximumAngle": {
- "type": "number",
- "minimum": -360,
- "maximum": 360
- },
- "MinimumDistance": {
- "type": "number",
- "minimum": 0
- },
- "MaximumDistance": {
- "type": "number",
- "exclusiveMinimum": 0
+ "Nodes": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "DataId": {
+ "type": "number",
+ "minimum": 30000,
+ "maximum": 50000
+ },
+ "Locations": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "Position": {
+ "$ref": "#/$defs/Vector3"
+ },
+ "MinimumAngle": {
+ "type": "number",
+ "minimum": -360,
+ "maximum": 360
+ },
+ "MaximumAngle": {
+ "type": "number",
+ "minimum": -360,
+ "maximum": 360
+ },
+ "MinimumDistance": {
+ "type": "number",
+ "minimum": 0
+ },
+ "MaximumDistance": {
+ "type": "number",
+ "exclusiveMinimum": 0
+ }
+ },
+ "required": [
+ "Position"
+ ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": [
+ "DataId"
+ ],
+ "additionalProperties": false
+ }
}
},
"required": [
- "DataId",
- "Position"
+ "Nodes"
],
"additionalProperties": false
}
Assignment(nameof(GatheringRoot.TerritoryId), root.TerritoryId, default)
.AsSyntaxNodeOrToken(),
Assignment(nameof(GatheringRoot.AetheryteShortcut), root.AetheryteShortcut, null),
- AssignmentList(nameof(GatheringRoot.Nodes), root.Nodes).AsSyntaxNodeOrToken()))));
+ AssignmentList(nameof(GatheringRoot.Groups), root.Groups).AsSyntaxNodeOrToken()))));
}
catch (Exception e)
{
Assignment(nameof(SkipAetheryteCondition.InSameTerritory),
skipAetheryteCondition.InSameTerritory, emptyAetheryte.InSameTerritory)))));
}
- else if (value is GatheringNodeLocation nodeLocation)
+ else if (value is GatheringNodeGroup nodeGroup)
{
- var emptyLocation = new GatheringNodeLocation();
return ObjectCreationExpression(
- IdentifierName(nameof(GatheringNodeLocation)))
+ IdentifierName(nameof(GatheringNodeGroup)))
.WithInitializer(
InitializerExpression(
SyntaxKind.ObjectInitializerExpression,
SeparatedList<ExpressionSyntax>(
SyntaxNodeList(
- Assignment(nameof(GatheringNodeLocation.DataId), nodeLocation.DataId,
+ AssignmentList(nameof(GatheringNodeGroup.Nodes), nodeGroup.Nodes)
+ .AsSyntaxNodeOrToken()))));
+ }
+ else if (value is GatheringNode nodeLocation)
+ {
+ var emptyLocation = new GatheringNode();
+ return ObjectCreationExpression(
+ IdentifierName(nameof(GatheringNode)))
+ .WithInitializer(
+ InitializerExpression(
+ SyntaxKind.ObjectInitializerExpression,
+ SeparatedList<ExpressionSyntax>(
+ SyntaxNodeList(
+ Assignment(nameof(GatheringNode.DataId), nodeLocation.DataId,
emptyLocation.DataId)
.AsSyntaxNodeOrToken(),
- Assignment(nameof(GatheringNodeLocation.Position), nodeLocation.Position,
+ AssignmentList(nameof(GatheringNode.Locations), nodeLocation.Locations)
+ .AsSyntaxNodeOrToken()))));
+ }
+ else if (value is GatheringLocation location)
+ {
+ var emptyLocation = new GatheringLocation();
+ return ObjectCreationExpression(
+ IdentifierName(nameof(GatheringLocation)))
+ .WithInitializer(
+ InitializerExpression(
+ SyntaxKind.ObjectInitializerExpression,
+ SeparatedList<ExpressionSyntax>(
+ SyntaxNodeList(
+ Assignment(nameof(GatheringLocation.Position), location.Position,
emptyLocation.Position).AsSyntaxNodeOrToken(),
- Assignment(nameof(GatheringNodeLocation.MinimumAngle), nodeLocation.MinimumAngle,
+ Assignment(nameof(GatheringLocation.MinimumAngle), location.MinimumAngle,
emptyLocation.MinimumAngle).AsSyntaxNodeOrToken(),
- Assignment(nameof(GatheringNodeLocation.MaximumAngle), nodeLocation.MaximumAngle,
+ Assignment(nameof(GatheringLocation.MaximumAngle), location.MaximumAngle,
emptyLocation.MaximumAngle).AsSyntaxNodeOrToken(),
- Assignment(nameof(GatheringNodeLocation.MinimumDistance),
- nodeLocation.MinimumDistance, emptyLocation.MinimumDistance)
+ Assignment(nameof(GatheringLocation.MinimumDistance),
+ location.MinimumDistance, emptyLocation.MinimumDistance)
.AsSyntaxNodeOrToken(),
- Assignment(nameof(GatheringNodeLocation.MaximumDistance),
- nodeLocation.MaximumDistance, emptyLocation.MaximumDistance)
+ Assignment(nameof(GatheringLocation.MaximumDistance),
+ location.MaximumDistance, emptyLocation.MaximumDistance)
.AsSyntaxNodeOrToken()))));
}
else if (value is null)
Culture = CultureInfo.InvariantCulture,
OutputFormat = OutputFormat.List,
});
- if (!evaluationResult.IsValid)
+ if (evaluationResult.HasErrors)
{
var error = Diagnostic.Create(invalidJson,
null,
Path.GetFileName(additionalFile.Path));
context.ReportDiagnostic(error);
+ continue;
}
yield return (id, node);
--- /dev/null
+using System.Numerics;
+using System.Text.Json.Serialization;
+using Questionable.Model.Common.Converter;
+
+namespace Questionable.Model.Gathering;
+
+public sealed class GatheringLocation
+{
+ [JsonConverter(typeof(VectorConverter))]
+ public Vector3 Position { get; set; }
+
+ public float? MinimumAngle { get; set; }
+ public float? MaximumAngle { get; set; }
+ public float MinimumDistance { get; set; } = 1f;
+ public float MaximumDistance { get; set; } = 3f;
+
+ public bool IsCone()
+ {
+ return MinimumAngle != null && MaximumAngle != null;
+ }
+}
--- /dev/null
+using System.Collections.Generic;
+
+namespace Questionable.Model.Gathering;
+
+public sealed class GatheringNode
+{
+ public uint DataId { get; set; }
+
+ public List<GatheringLocation> Locations { get; set; } = [];
+}
--- /dev/null
+using System.Collections.Generic;
+
+namespace Questionable.Model.Gathering;
+
+public sealed class GatheringNodeGroup
+{
+ public List<GatheringNode> Nodes { get; set; } = [];
+}
+++ /dev/null
-using System.Numerics;
-
-namespace Questionable.Model.Gathering;
-
-public sealed class GatheringNodeLocation
-{
- public uint DataId { get; set; }
- public Vector3 Position { get; set; }
- public float? MinimumAngle { get; set; }
- public float? MaximumAngle { get; set; }
- public float? MinimumDistance { get; set; } = 0.5f;
- public float? MaximumDistance { get; set; } = 3f;
-}
[JsonConverter(typeof(AetheryteConverter))]
public EAetheryteLocation? AetheryteShortcut { get; set; }
- public List<GatheringNodeLocation> Nodes { get; set; } = [];
+ public List<GatheringNodeGroup> Groups { get; set; } = [];
}
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatheringPathRenderer", "GatheringPathRenderer\GatheringPathRenderer.csproj", "{F514DA95-9867-4F3F-8062-ACE0C62E8740}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECommons", "vendor\ECommons\ECommons\ECommons.csproj", "{A12D7B4B-8E6E-4DCF-A41A-12F62E9FF94B}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
{F514DA95-9867-4F3F-8062-ACE0C62E8740}.Debug|x64.Build.0 = Debug|Any CPU
{F514DA95-9867-4F3F-8062-ACE0C62E8740}.Release|x64.ActiveCfg = Release|Any CPU
{F514DA95-9867-4F3F-8062-ACE0C62E8740}.Release|x64.Build.0 = Release|Any CPU
+ {A12D7B4B-8E6E-4DCF-A41A-12F62E9FF94B}.Debug|x64.ActiveCfg = Debug|x64
+ {A12D7B4B-8E6E-4DCF-A41A-12F62E9FF94B}.Debug|x64.Build.0 = Debug|x64
+ {A12D7B4B-8E6E-4DCF-A41A-12F62E9FF94B}.Release|x64.ActiveCfg = Release|x64
+ {A12D7B4B-8E6E-4DCF-A41A-12F62E9FF94B}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
using System.Text.Json;
using System.Text.Json.Nodes;
using Dalamud.Plugin;
+using Dalamud.Plugin.Ipc;
using Microsoft.Extensions.Logging;
using Questionable.Data;
using Questionable.Model;
private readonly IDalamudPluginInterface _pluginInterface;
private readonly QuestData _questData;
private readonly QuestValidator _questValidator;
- private readonly ILogger<QuestRegistry> _logger;
private readonly JsonSchemaValidator _jsonSchemaValidator;
+ private readonly ILogger<QuestRegistry> _logger;
+ private readonly ICallGateProvider<object> _reloadDataIpc;
private readonly Dictionary<ushort, Quest> _quests = new();
_questValidator = questValidator;
_jsonSchemaValidator = jsonSchemaValidator;
_logger = logger;
+ _reloadDataIpc = _pluginInterface.GetIpcProvider<object>("Questionable.ReloadData");
}
public IEnumerable<Quest> AllQuests => _quests.Values;
ValidateQuests();
Reloaded?.Invoke(this, EventArgs.Empty);
+ _reloadDataIpc.SendMessage();
_logger.LogInformation("Loaded {Count} quests in total", _quests.Count);
}
--- /dev/null
+Subproject commit 9e90d0032f0efd4c9e65d9c5a8e8bd0e99557d68