Update project

This commit is contained in:
Nathan C 2025-03-15 22:10:24 -04:00
parent 93231e5419
commit 6f95fcde97
No known key found for this signature in database
GPG Key ID: 6094F8F8D02EA281
27 changed files with 494 additions and 248 deletions

View File

@ -4,7 +4,7 @@ namespace QuestShare.Common
{
public class Constants
{
public static readonly string Version = "3";
public static readonly string Version = "1";
}
public static class StringExtensions

View File

@ -24,5 +24,6 @@ namespace QuestShare.Common
BannedTooManyBadRequests,
AlreadyRegistered,
AlreadyJoined,
ServerMaintenance,
}
}

View File

@ -21,6 +21,7 @@ namespace QuestShare.Common
public required string OwnerCharacterId { get; set; }
public int ActiveQuestId { get; set; }
public byte ActiveQuestStep { get; set; }
public bool IsValid { get; set; } = true;
}
public record OwnedSession

View File

@ -19,8 +19,8 @@ public class ConfigurationManager
public List<Objects.ShareCode> KnownShareCodes { get; set; } = [];
public int ActiveQuestId { get; set; } = 0;
public byte ActiveQuestStep { get; set; } = 0;
public Dictionary<long, string> KnownCharacters { get; set; } = [];
public ApiConfiguration[] ApiConfigurations { get; set; } = [new ApiConfiguration { DisplayName = "Primary Server - US East", Url = "https://api.nathanc.tech/Hub", Active = false }];
public Dictionary<string, string> ShareCodeOwners { get; set; } = [];
public List<ApiConfiguration> ApiConfigurations { get; set; } = [];
public string ApiUrl => ApiConfigurations.FirstOrDefault(x => x.Active)?.Url ?? "https://api.nathanc.tech/Hub";
public string ApiDisplayName => ApiConfigurations.FirstOrDefault(x => x.Active)?.DisplayName ?? "Primary Server - US East";
}
@ -44,14 +44,14 @@ public class ConfigurationManager
if (ClientState.LocalContentId != 0)
{
LocalContentId = ClientState.LocalContentId;
Framework.Run(Load);
Load();
}
}
public void OnLogin()
{
LocalContentId = ClientState.LocalContentId;
Framework.Run(Load);
Load();
}
public void Dispose()
@ -77,18 +77,12 @@ public class ConfigurationManager
if (deserialized != null)
{
Instance = deserialized;
#if DEBUG
if (Instance.ApiConfigurations.All(x => x.DisplayName != "Local"))
Instance.ApiConfigurations.Append(new ApiConfiguration { DisplayName = "Local", Url = "http://localhost:8080/Hub", Active = true });
foreach (var item in Instance.ApiConfigurations)
{
if (item.DisplayName != "Local") item.Active = false;
}
#endif
}
else
{
Log.Error($"Failed to deserialize configuration for {LocalContentId}");
Log.Error($"Failed to deserialize configuration for {LocalContentId}, using defaults.");
Instance = new Configuration();
Save();
}
}
}

View File

@ -54,11 +54,17 @@ namespace QuestShare.Common
GameQuests.Add(new GameQuest(quest.RowId));
}
}
if (activeQuest != null)
if (activeQuest != null && !GameQuests.Any(q => q.QuestId == activeQuest.QuestId))
{
Log.Debug($"Active quest {activeQuest.QuestId} was not found in GameQuests, assuming completed.");
HostService.Update(0, 0);
return;
}
else if (activeQuest != null)
{
SetActiveFlag(activeQuest.QuestId);
}
else if (ConfigurationManager.Instance.OwnedSession != null)
else if (ConfigurationManager.Instance.OwnedSession != null && ConfigurationManager.Instance.OwnedSession.ActiveQuestId != 0)
{
var quest = GetQuestById((uint)ConfigurationManager.Instance.OwnedSession.ActiveQuestId);
SetActiveFlag(quest.QuestId);
@ -149,6 +155,8 @@ namespace QuestShare.Common
Log.Debug("No next quest in chain");
GameQuests.Remove(q);
LoadQuests();
HostService.Update(0, 0);
return;
}
}
HostService.Update((int)q.QuestId, q.CurrentStep);

View File

@ -11,7 +11,7 @@ namespace QuestShare;
public sealed class Plugin : IDalamudPlugin
{
public static string Name => "Quest Share";
public static string Version => "1.0.0";
public static string Version => "0.0.0.1";
public static string PluginDataPath { get; private set; } = null!;
internal static ConfigurationManager Configuration { get; private set; } = null!;
private static List<IService> Services = [];
@ -33,8 +33,10 @@ public sealed class Plugin : IDalamudPlugin
];
GameQuestManager.Initialize();
LogStream = new StringWriter();
#if DEBUG
Console.SetOut(LogStream);
Console.SetError(LogStream);
#endif
Framework.Update += OnFramework;
Log.Debug($"Token: {ConfigurationManager.Instance.Token}");
foreach (var service in Services)
@ -65,6 +67,7 @@ public sealed class Plugin : IDalamudPlugin
private void OnFramework(IFramework framework)
{
#if DEBUG
// check if there's logs to write
if (LogStream != null && LogStream.ToString() != "")
{
@ -72,5 +75,7 @@ public sealed class Plugin : IDalamudPlugin
LogStream.GetStringBuilder().Clear();
Log.Write(LogEventLevel.Debug, null, toWrite);
}
#endif
}
}

View File

@ -2,12 +2,13 @@
<Project Sdk="Dalamud.NET.SDK/11.2.0">
<PropertyGroup>
<Version>0.0.0.1</Version>
<Description>A sample plugin.</Description>
<PackageProjectUrl>https://github.com/goatcorp/SamplePlugin</PackageProjectUrl>
<Description>Share quests with your friends so they can follow you on your journey.</Description>
<PackageProjectUrl>https://github.com/Era-FFXIV/QuestShare</PackageProjectUrl>
<PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression>
<IsPackable>false</IsPackable>
<ImplicitUsings>enable</ImplicitUsings>
<TargetFramework>net8.0-windows</TargetFramework>
<RepositoryUrl>https://github.com/Era-FFXIV/QuestShare</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<Content Include="QuestShare.json" />

View File

@ -2,13 +2,13 @@
"Author": "Era",
"Name": "Quest Share",
"Punchline": "Share your quest progress with others.",
"Description": "blah",
"Description": "Follow your friends on their questing journey by seeing their progress through a shared quest. Provides markers for quest destinations and (soon) teleporting features.",
"ApplicableVersion": "any",
"Tags": [
"utility",
"vendor",
"shop"
"questing",
"coop"
],
"RepoUrl": "",
"RepoUrl": "https://github.com/Era-FFXIV/QuestShare",
"AcceptsFeedback": false
}

View File

@ -30,19 +30,51 @@ namespace QuestShare.Services.API
ConfigurationManager.Instance.Token = response.Token;
foreach(var session in response.Sessions)
{
if (!session.IsValid)
{
Log.Warning("Session {ShareCode} is invalid.", session.ShareCode);
share.RemoveSession(session.ShareCode);
ShareService.RemoveKnownShareCode(session.ShareCode);
continue;
}
share.AddSession(session);
}
if (response.OwnedSession != null)
{
ConfigurationManager.Instance.OwnedSession = response.OwnedSession;
if (response.OwnedSession.Session != null)
{
Log.Debug("Setting active quest to {QuestId} - {QuestStep}", response.OwnedSession.Session.ActiveQuestId, response.OwnedSession.Session.ActiveQuestStep);
GameQuestManager.SetActiveFlag((uint)response.OwnedSession.Session.ActiveQuestId);
}
}
ConfigurationManager.Save();
ShareService.RecheckShareCodes();
}
else
{
Log.Error("Failed to authorize: {Error}", response.Error);
UiService.LastErrorMessage = $"Failed to authorize: {response.Error}";
_ = ((ApiService)Plugin.GetService<ApiService>()).Disconnect();
if (response.Error == Error.InvalidVersion)
{
UiService.LastServerMessage = "Invalid version detected, please update the plugin.";
((ApiService)Plugin.GetService<ApiService>()).IsLockedOut = true;
}
else if (response.Error == Error.InvalidToken)
{
UiService.LastServerMessage = "Invalid token detected, please reauthorize.";
((ApiService)Plugin.GetService<ApiService>()).IsLockedOut = true;
}
else if (response.Error == Error.BannedTooManyBadRequests)
{
UiService.LastServerMessage = "You are temporarily banned due to too many bad requests.";
((ApiService)Plugin.GetService<ApiService>()).IsLockedOut = true;
}
else if (response.Error == Error.ServerMaintenance)
{
UiService.LastServerMessage = "Server is currently undergoing maintenance. Please try again later.";
}
}
return Task.CompletedTask;
}

View File

@ -21,6 +21,7 @@ namespace QuestShare.Services.API
var share = (ShareService)Plugin.GetService<ShareService>();
Log.Information("Successfully joined group.");
share.AddSession(resp.Session);
ShareService.RecheckShareCodes();
var api = (ApiService)Plugin.GetService<ApiService>();
api.OnGroupJoin(new ApiService.GroupJoinEventArgs { Session = resp.Session, IsSuccess = true });
}

View File

@ -26,8 +26,19 @@ namespace QuestShare.Services.API
{
Log.Information("Session started successfully");
ConfigurationManager.Instance.OwnedSession = response.Session;
// check if hashes match
if (response.Session!.OwnerCharacterId != ClientState.LocalContentId.ToString().SaltedHash(response.Session.ShareCode))
{
Log.Error($"Mismatched owner character ID! {response.Session!.OwnerCharacterId} != {ClientState.LocalContentId.ToString().SaltedHash(response.Session.ShareCode)}");
}
ConfigurationManager.Save();
HostService.UpdateParty();
if (GameQuestManager.GetActiveQuest() != null)
{
HostService.Update(GameQuestManager.GetActiveQuest()!.QuestId, GameQuestManager.GetActiveQuest()!.CurrentStep);
} else
{
HostService.UpdateParty();
}
}
else
{

View File

@ -11,18 +11,18 @@ namespace QuestShare.Services
internal class ApiService : IService
{
private readonly string socketUrl = ConfigurationManager.Instance.ApiUrl;
private HubConnection ApiConnection { get; set; } = null!;
internal bool IsConnected => ApiConnection.State == HubConnectionState.Connected;
internal bool IsLockedOut { get; set; } = false;
internal HubConnectionState ConnectionState => ApiConnection.State;
internal bool IsAuthorized { get; private set; } = false;
private bool isDisposing = false;
internal static string Token => ConfigurationManager.Instance.Token;
private readonly List<IAPIHandler> apiHandlers = [];
private int retryCount = 0;
public void Initialize()
{
var builder = new HubConnectionBuilder().WithUrl(socketUrl).ConfigureLogging(logging =>
var builder = new HubConnectionBuilder().WithUrl(ConfigurationManager.Instance.ApiUrl).ConfigureLogging(logging =>
{
logging.SetMinimumLevel(LogLevel.Information).AddConsole();
});
@ -31,13 +31,20 @@ namespace QuestShare.Services
{
if (isDisposing) return;
Log.Warning($"Connection closed... {error}");
Log.Warning($"Connection closed, retrying... {error}");
await Task.Delay(new Random().Next(0, 5) * 1000);
await ApiConnection.StartAsync();
if (retryCount < 3)
{
retryCount++;
await Task.Delay(new Random().Next(0, 5) * 1000);
await ApiConnection.StartAsync();
} else
{
Log.Error("Failed to reconnect after 3 attempts, giving up.");
}
};
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
ApiConnection.Reconnected += async (error) =>
{
retryCount = 0;
Log.Information("Connection reconnected");
};
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
@ -58,6 +65,10 @@ namespace QuestShare.Services
ClientState.Login += OnLogin;
ClientState.Logout += OnLogout;
Framework.Update += SavePersistedConfig;
if (ConfigurationManager.Instance.ConnectOnStartup)
{
Task.Run(Connect);
}
}
public void Shutdown()
{
@ -83,7 +94,6 @@ namespace QuestShare.Services
public void OnLogout(int code, int type)
{
ConfigurationManager.Save();
IsAuthorized = false;
ApiConnection.StopAsync().ConfigureAwait(false);
}
@ -92,8 +102,15 @@ namespace QuestShare.Services
ConfigurationManager.Instance.Token = Token;
}
public void OnUrlChange()
{
Shutdown();
Initialize();
}
public async Task Connect()
{
Log.Debug($"Attempting to connect to {ConfigurationManager.Instance.ApiUrl}");
try
{
if (IsConnected) await ApiConnection.StopAsync();
@ -107,6 +124,7 @@ namespace QuestShare.Services
else
{
Log.Information("Connected to socket server");
retryCount = 0;
}
});
}

View File

@ -1,4 +1,5 @@
using Dalamud.Plugin.Services;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
@ -24,6 +25,7 @@ namespace QuestShare.Services
{
ClientState.Login += OnLogin;
ClientState.Logout += OnLogout;
CharacterId = ClientState.LocalContentId;
}
public void Shutdown()
@ -51,6 +53,9 @@ namespace QuestShare.Services
SkipPartyCheck = false,
Session = session,
};
Log.Debug($"Hashing {CharacterId} with {shareCode} to get ${session.OwnerCharacterId}");
Log.Debug(JsonConvert.SerializeObject(ownedSession));
Log.Debug(JsonConvert.SerializeObject(session));
ApiService.DispatchSessionStart(ownedSession);
}
@ -125,5 +130,10 @@ namespace QuestShare.Services
var api = (ApiService)Plugin.GetService<ApiService>();
ApiService.DispatchCancel();
}
internal static void Update(uint questId, byte currentStep)
{
Update((int)questId, currentStep);
}
}
}

View File

@ -5,8 +5,8 @@ namespace QuestShare.Services
internal class ShareService : IService
{
internal static List<Objects.ShareCode> ShareCodes => ConfigurationManager.Instance.KnownShareCodes;
internal static Dictionary<string, string> ShareCodeOwners => ConfigurationManager.Instance.ShareCodeOwners;
internal List<Objects.Session> Sessions { get; private set; } = [];
internal Dictionary<long, string> CharacterLookup { get; private set; } = [];
public void Initialize()
{
@ -21,38 +21,46 @@ namespace QuestShare.Services
ClientState.Login -= OnLogin;
ClientState.Logout -= OnLogout;
Framework.Update -= OnFrameworkUpdate;
CharacterLookup.Clear();
Sessions.Clear();
}
private void OnLogin()
{
CharacterLookup.Clear();
CharacterLookup.Add((long)ClientState.LocalContentId, ClientState.LocalPlayer!.Name.TextValue);
foreach (var character in ConfigurationManager.Instance.KnownCharacters)
{
CharacterLookup.Add(character.Key, character.Value);
}
}
private void OnLogout(int code, int state)
{
ConfigurationManager.Save();
CharacterLookup.Clear();
Sessions.Clear();
}
private int pCount = 0;
private static bool RecheckPending = false;
public static void RecheckShareCodes()
{
RecheckPending = true;
}
private void OnFrameworkUpdate(IFramework framework)
{
if (PartyList.Count != pCount)
if (PartyList.Count != pCount || RecheckPending)
{
Log.Debug("Party list changed");
foreach (var partyMember in PartyList)
{
AddKnownCharacter(partyMember.ContentId, partyMember.Name.TextValue);
Log.Debug($"Checking party member {partyMember.Name.TextValue} {partyMember.ContentId}");
foreach (var session in Sessions)
{
Log.Debug($"Checking session {session.ShareCode} - {session.OwnerCharacterId} ==? {partyMember.ContentId.ToString().SaltedHash(session.ShareCode)}");
if (session.OwnerCharacterId == partyMember.ContentId.ToString().SaltedHash(session.ShareCode))
{
Log.Debug($"Setting share code owner for {session.ShareCode} to {partyMember.Name.TextValue}");
ConfigurationManager.Instance.ShareCodeOwners[session.ShareCode] = partyMember.Name.TextValue;
}
}
}
pCount = PartyList.Count;
RecheckPending = false;
}
}
@ -111,20 +119,13 @@ namespace QuestShare.Services
ConfigurationManager.Instance.KnownShareCodes.RemoveAll(sc => sc.Code == shareCode);
}
public static void AddKnownCharacter(long contentId, string characterId)
public static string GetShareCodeOwner(string shareCode)
{
if (ConfigurationManager.Instance.KnownCharacters.ContainsKey(contentId))
if (ConfigurationManager.Instance.ShareCodeOwners.ContainsKey(shareCode))
{
return;
return ConfigurationManager.Instance.ShareCodeOwners[shareCode];
}
ConfigurationManager.Instance.KnownCharacters.Add(contentId, characterId);
ConfigurationManager.Save();
}
public static void RemoveKnownCharacter(long contentId)
{
ConfigurationManager.Instance.KnownCharacters.Remove(contentId);
ConfigurationManager.Save();
return "Unknown";
}
}

View File

@ -8,6 +8,7 @@ namespace QuestShare.Services
public static WindowSystem WindowSystem = new("QuestShare");
public static MainWindow MainWindow { get; private set; } = new();
public static string LastErrorMessage { get; set; } = string.Empty;
public static string LastServerMessage { get; set; } = string.Empty;
public void Initialize()
{

View File

@ -7,6 +7,7 @@ using Dalamud.Interface.Windowing;
using ImGuiNET;
using Microsoft.AspNetCore.SignalR.Client;
using QuestShare.Services;
using static QuestShare.Common.ConfigurationManager;
using static QuestShare.Services.ApiService;
namespace QuestShare.Windows.MainWindow;
@ -47,6 +48,7 @@ public class MainWindow : Window, IDisposable
ImGui.TextUnformatted("Server Status: "); ImGui.SameLine();
DrawConnectionState();
ImGui.SameLine();
ImGui.BeginDisabled(ApiService.IsLockedOut);
if (ApiService.IsConnected)
{
if (ImGuiComponents.IconButton(FontAwesomeIcon.Unlink, ImGuiColors.DPSRed))
@ -58,9 +60,12 @@ public class MainWindow : Window, IDisposable
{
if (ImGuiComponents.IconButton(FontAwesomeIcon.Link, ImGuiColors.DPSRed))
{
UiService.LastErrorMessage = "";
UiService.LastServerMessage = "";
_ = ApiService.Connect();
}
}
ImGui.EndDisabled();
// ImGui.SameLine();
ImGui.Separator();
using (ImRaii.TabBar("MainTabBar", ImGuiTabBarFlags.NoCloseWithMiddleMouseButton))
@ -84,14 +89,19 @@ public class MainWindow : Window, IDisposable
}
}
ImGui.Separator();
if (UiService.LastErrorMessage != null)
if (UiService.LastErrorMessage != "")
{
// ImGui.TextColored(ImGuiColors.DPSRed, UiService.LastErrorMessage);
ImGui.TextColored(ImGuiColors.DPSRed, UiService.LastErrorMessage);
}
}
private void DrawConnectionState()
{
if (UiService.LastServerMessage != "")
{
ImGui.TextColored(ImGuiColors.DalamudYellow, UiService.LastServerMessage);
return;
}
switch (this.ApiService.ConnectionState)
{
case HubConnectionState.Connecting:
@ -116,135 +126,117 @@ public class MainWindow : Window, IDisposable
private void DrawHostTab()
{
if (HostService.ActiveSession != null && HostService.ActiveSession.ShareCode != null) generatePending = false;
var isEnabled = ConfigurationManager.Instance.EnableHosting;
if (ImGuiComponents.ToggleButton(Namespace + "/Enable Hosting", ref isEnabled))
{
ConfigurationManager.Instance.EnableHosting = isEnabled;
ConfigurationManager.Save();
}
ImGui.TextUnformatted("Share Code:");
ImGui.SameLine();
ImGui.TextUnformatted("Enable Hosting");
ImGui.Separator();
if (isEnabled)
if (HostService.ActiveSession != null)
{
ImGui.TextUnformatted("Share Code:");
ImGui.SameLine();
if (HostService.ActiveSession != null)
ImGui.TextColored(ImGuiColors.HealerGreen, HostService.ActiveSession.ShareCode);
if (ImGui.IsItemClicked())
{
ImGui.TextColored(ImGuiColors.HealerGreen, HostService.ActiveSession.ShareCode);
if (ImGui.IsItemClicked())
ImGui.SetClipboardText(HostService.ActiveSession.ShareCode);
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Click to copy to clipboard.");
}
ImGui.SameLine();
if (ImGui.Button("Cancel"))
{
DispatchCancel();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Cancel the current session. This will permanently remove the share code and all connected clients.");
}
var allowJoins = HostService.AllowJoins;
var skipPartyCheck = HostService.SkipPartyCheck;
var sendUpdates = HostService.IsActive;
if (ToggleButtonWithHelpMarker("Allow new Joins", "Allows new players to join the group.", ref allowJoins))
{
HostService.SetAllowJoins(allowJoins);
}
if (ToggleButtonWithHelpMarker("Skip Party Check", "Allows new players to join the group without being in your party first.", ref skipPartyCheck))
{
HostService.SetSkipPartyCheck(skipPartyCheck);
}
if (ToggleButtonWithHelpMarker("Send Updates", "Sends quest updates to the server.", ref sendUpdates))
{
HostService.SetIsActive(sendUpdates);
}
var track = Instance.TrackMSQ;
if (ToggleButtonWithHelpMarker("Track MSQ", "Automatically track the Main Scenario Quest.", ref track))
{
Instance.TrackMSQ = track;
Save();
}
ImGui.BeginDisabled(track);
using (var combo = ImRaii.Combo("##Quests", GameQuestManager.GetActiveQuest()?.QuestName ?? "---SELECT---", ImGuiComboFlags.HeightRegular))
{
if (combo)
{
ImGui.SetClipboardText(HostService.ActiveSession.ShareCode);
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Click to copy to clipboard.");
}
ImGui.SameLine();
if (ImGui.Button("Cancel"))
{
DispatchCancel();
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Cancel the current session. This will permanently remove the share code and all connected clients.");
}
var allowJoins = HostService.AllowJoins;
var skipPartyCheck = HostService.SkipPartyCheck;
var sendUpdates = HostService.IsActive;
if (ImGui.Checkbox("Allow new Joins", ref allowJoins))
{
HostService.SetAllowJoins(allowJoins);
}
ImGui.SameLine();
if (ImGui.Checkbox("Skip Party Check", ref skipPartyCheck))
{
HostService.SetSkipPartyCheck(skipPartyCheck);
}
ImGui.SameLine();
if (ImGui.Checkbox("Send Updates", ref sendUpdates))
{
HostService.SetIsActive(sendUpdates);
}
var shareMsq = ConfigurationManager.Instance.TrackMSQ;
ImGui.BeginDisabled(shareMsq);
using (var combo = ImRaii.Combo("##Quests", GameQuestManager.GetActiveQuest()?.QuestName ?? "---SELECT---", ImGuiComboFlags.HeightRegular))
{
if (combo)
foreach (var quest in GameQuestManager.GameQuests.OrderBy(q => q.QuestName))
{
foreach (var quest in GameQuestManager.GameQuests.OrderBy(q => q.QuestName))
if (ImGui.Selectable(quest.QuestName))
{
if (ImGui.Selectable(quest.QuestName))
{
selectedQuest = quest;
GameQuestManager.SetActiveFlag(quest.QuestId);
HostService.Update((int)quest.QuestId, quest.CurrentStep);
ConfigurationManager.Save();
}
selectedQuest = quest;
GameQuestManager.SetActiveFlag(quest.QuestId);
HostService.Update((int)quest.QuestId, quest.CurrentStep);
Save();
}
}
}
ImGui.SameLine();
if (ImGui.Button("Refresh"))
{
GameQuestManager.LoadQuests();
}
ImGui.EndDisabled();
ImGui.SameLine();
var track = ConfigurationManager.Instance.TrackMSQ;
if (ImGui.Checkbox("Track MSQ", ref track))
{
ConfigurationManager.Instance.TrackMSQ = track;
ConfigurationManager.Save();
}
if (selectedQuest == null && GameQuestManager.GetActiveQuest() == null)
{
ImGui.TextUnformatted("No quest selected.");
return;
}
else if (GameQuestManager.GetActiveQuest() != null)
{
selectedQuest = GameQuestManager.GetActiveQuest();
}
ImGui.TextUnformatted("Active Quest:");
ImGui.SameLine();
ImGui.TextUnformatted(selectedQuest!.QuestName);
ImGui.TextUnformatted("Current Step:");
ImGui.SameLine();
ImGui.TextUnformatted(selectedQuest.CurrentStep.ToString());
ImGui.Separator();
ImGui.TextUnformatted("Quest Steps:");
ImGui.Separator();
}
ImGui.SameLine();
if (ImGui.Button("Refresh"))
{
GameQuestManager.LoadQuests();
}
ImGui.EndDisabled();
// add a line of space
ImGui.TextUnformatted(" ");
if (selectedQuest == null && GameQuestManager.GetActiveQuest() == null)
{
ImGui.TextUnformatted("No quest selected.");
return;
}
else if (GameQuestManager.GetActiveQuest() != null)
{
selectedQuest = GameQuestManager.GetActiveQuest();
}
ImGui.TextUnformatted("Active Quest:");
ImGui.SameLine();
ImGui.TextUnformatted(selectedQuest!.QuestName);
ImGui.Separator();
var steps = selectedQuest.QuestSteps;
for (var i = 0; i < steps!.Count; i++)
var steps = selectedQuest.QuestSteps;
for (var i = 0; i < steps!.Count; i++)
{
var questText = Instance.HideFutureStepsHost && i + 1 > selectedQuest.CurrentStep ? "???" : steps[i];
if (i + 1 == selectedQuest.CurrentStep || (selectedQuest.CurrentStep == 0 && i == 0) || (selectedQuest.CurrentStep == 0xFF && i + 1 == steps.Count))
{
if (i + 1 == selectedQuest.CurrentStep || (selectedQuest.CurrentStep == 0 && i == 0) || (selectedQuest.CurrentStep == 0xFF && i + 1 == steps.Count))
{
ImGui.TextColored(ImGuiColors.HealerGreen, steps[i]);
}
else if (i + 1 < selectedQuest.CurrentStep)
{
ImGui.TextColored(ImGuiColors.DalamudYellow, steps[i]);
}
else
{
ImGui.TextUnformatted("???");
}
ImGui.TextColored(ImGuiColors.HealerGreen, questText);
}
} else {
ImGui.BeginDisabled(generatePending);
if (ImGui.Button("Generate New"))
else if (i + 1 < selectedQuest.CurrentStep)
{
ApiService.DispatchRegister();
ImGui.TextColored(ImGuiColors.DalamudYellow, questText);
}
else
{
ImGui.TextUnformatted(questText);
}
ImGui.EndDisabled();
}
}
else
{
ImGui.BeginDisabled(generatePending);
if (ImGui.Button("Generate New"))
{
DispatchRegister();
}
ImGui.EndDisabled();
}
}
@ -270,7 +262,7 @@ public class MainWindow : Window, IDisposable
var payload = new Objects.ShareCode { CharacterId = ClientState.LocalContentId.ToString().SaltedHash(enteredShareCode), Code = enteredShareCode };
isJoining = true;
ApiService.GroupJoined += OnGroupJoin;
ApiService.DispatchGroup(payload);
DispatchGroup(payload);
}
ImGui.EndDisabled();
ImGui.Separator();
@ -289,7 +281,7 @@ public class MainWindow : Window, IDisposable
DrawSessionDetails(session);
if (ImGui.Button("Leave Group"))
{
ApiService.DispatchUngroup(session);
DispatchUngroup(session);
isLeaving = true;
}
}
@ -300,7 +292,7 @@ public class MainWindow : Window, IDisposable
private void DrawSessionDetails(Objects.Session session)
{
ImGui.TextUnformatted($"Owner: {session.OwnerCharacterId}");
ImGui.TextUnformatted($"Owner: {ShareService.GetShareCodeOwner(session.ShareCode)}");
var activeQuest = session.ActiveQuestId;
var activeStep = session.ActiveQuestStep;
if (activeQuest != 0)
@ -309,21 +301,17 @@ public class MainWindow : Window, IDisposable
var steps = questInfo.QuestSteps;
ImGui.TextUnformatted(questInfo.QuestData.Name.ExtractText());
ImGui.TextUnformatted("Current Step:");
ImGui.SameLine();
ImGui.TextUnformatted(activeStep.ToString());
ImGui.Separator();
ImGui.TextUnformatted("Quest Steps:");
ImGui.Separator();
for (var i = 0; i < steps.Count; i++)
{
var questText = Instance.HideFutureStepsMember && i + 1 > activeStep ? "???" : steps[i];
if (i + 1 == activeStep || (i + 1 == steps.Count && activeStep == 0xFF))
{
ImGui.TextColored(ImGuiColors.HealerGreen, steps[i]);
ImGui.TextColored(ImGuiColors.HealerGreen, questText);
}
else
{
ImGui.TextUnformatted(steps[i]);
ImGui.TextUnformatted(questText);
}
}
if (ImGui.Button("Get Marker"))
@ -350,31 +338,36 @@ public class MainWindow : Window, IDisposable
}*/
} else
{
ImGui.TextUnformatted("No active quest or host is offline.");
ImGui.TextUnformatted("No active quest.");
}
}
string newServerDisplayName = "";
string newServerUrl = "";
private void DrawSettingsTab()
{
var selectedApiServer = ConfigurationManager.Instance.ApiDisplayName;
var selectedApiServer = Instance.ApiDisplayName;
ImGui.BeginDisabled(Instance.ApiConfigurations.Count < 1);
if (ImGui.BeginCombo("API Server", selectedApiServer))
{
foreach (var server in ConfigurationManager.Instance.ApiConfigurations)
foreach (var server in Instance.ApiConfigurations)
{
var isSelected = selectedApiServer == server.DisplayName;
if (ImGui.Selectable(server.DisplayName, isSelected))
{
var index = Array.FindIndex(ConfigurationManager.Instance.ApiConfigurations, x => x.DisplayName == server.DisplayName);
ConfigurationManager.Instance.ApiConfigurations[index].Active = true;
foreach (var config in ConfigurationManager.Instance.ApiConfigurations)
var index = Array.FindIndex<ApiConfiguration>([.. Instance.ApiConfigurations], x => x.DisplayName == server.DisplayName);
Instance.ApiConfigurations[index].Active = true;
foreach (var config in Instance.ApiConfigurations)
{
if (config.DisplayName != server.DisplayName)
{
config.Active = false;
}
}
ConfigurationManager.Save();
Save();
Framework.Run(ApiService.OnUrlChange);
}
if (isSelected)
{
@ -383,29 +376,117 @@ public class MainWindow : Window, IDisposable
}
ImGui.EndCombo();
}
ImGui.EndDisabled();
ImGui.SameLine();
if (ImGui.Button("Add"))
{
// does nothing yet
// pop up a dialog to add a new server
ImGui.OpenPopup("Add Server");
}
var connectOnStartup = ConfigurationManager.Instance.ConnectOnStartup;
if (ImGui.Checkbox("Connect on Startup", ref connectOnStartup))
ImGui.SameLine();
ImGui.BeginDisabled(Instance.ApiConfigurations.Count < 1);
if (ImGui.Button("Delete"))
{
ConfigurationManager.Instance.ConnectOnStartup = connectOnStartup;
ConfigurationManager.Save();
// pop up a dialog to delete the selected server
ImGui.OpenPopup("Delete?");
}
var autoShareMsq = ConfigurationManager.Instance.AutoShareMsq;
if (ImGui.Checkbox("Auto Share MSQ", ref autoShareMsq))
ImGui.EndDisabled();
var center = ImGui.GetMainViewport().GetCenter();
ImGui.SetNextWindowPos(center, ImGuiCond.Appearing, new Vector2(0.5f, 0.5f));
var open = true;
using (var addServer = ImRaii.PopupModal("Add Server", ref open, ImGuiWindowFlags.AlwaysAutoResize))
{
ConfigurationManager.Instance.AutoShareMsq = autoShareMsq;
ConfigurationManager.Save();
if (addServer)
{
ImGui.TextUnformatted("Add a new API server configuration.");
ImGui.InputText("Display Name", ref newServerDisplayName, 64);
ImGui.InputText("URL", ref newServerUrl, 200);
ImGui.TextUnformatted("Note: The URL should be the full URL to the hub endpoint and MUST be ws:// or wss:// (preferred)");
bool isValid = Uri.TryCreate(newServerUrl, UriKind.Absolute, out var uriResult) && (uriResult.Scheme == Uri.UriSchemeWs || uriResult.Scheme == Uri.UriSchemeWss);
ImGui.BeginDisabled(!isValid || string.IsNullOrEmpty(newServerDisplayName) || string.IsNullOrEmpty(newServerUrl));
if (ImGui.Button("Save"))
{
if (Instance.ApiConfigurations.Count == 0)
{
// add default server first
Instance.ApiConfigurations.Add(new ApiConfiguration { DisplayName = ConfigurationManager.Instance.ApiDisplayName, Url = ConfigurationManager.Instance.ApiUrl, Active = false });
}
Instance.ApiConfigurations.Add(new ApiConfiguration { DisplayName = newServerDisplayName, Url = newServerUrl, Active = true });
Save();
ImGui.CloseCurrentPopup();
newServerUrl = "";
newServerDisplayName = "";
}
ImGui.EndDisabled();
ImGui.SameLine();
if (ImGui.Button("Cancel"))
{
ImGui.CloseCurrentPopup();
newServerUrl = "";
newServerDisplayName = "";
}
}
}
var autoShareNewQuests = ConfigurationManager.Instance.AutoShareNewQuests;
if (ImGui.Checkbox("Auto Share New Quests", ref autoShareNewQuests))
var deleteServer = false;
using (var delServer = ImRaii.PopupModal("Delete?", ref deleteServer, ImGuiWindowFlags.AlwaysAutoResize))
{
ConfigurationManager.Instance.AutoShareNewQuests = autoShareNewQuests;
ConfigurationManager.Save();
if (delServer)
{
ImGui.TextUnformatted("Are you sure you want to delete this server?");
if (ImGui.Button("Yes"))
{
var index = Array.FindIndex<ApiConfiguration>([.. Instance.ApiConfigurations], x => x.DisplayName == selectedApiServer);
Instance.ApiConfigurations.RemoveAt(index);
Save();
ImGui.CloseCurrentPopup();
Framework.Run(ApiService.OnUrlChange);
}
ImGui.SameLine();
if (ImGui.Button("No"))
{
ImGui.CloseCurrentPopup();
}
}
}
var connectOnStartup = Instance.ConnectOnStartup;
if (ToggleButtonWithHelpMarker("Connect on Startup", "Automatically connect to the selected API server when the game is started.", ref connectOnStartup))
{
Instance.ConnectOnStartup = connectOnStartup;
Save();
}
var autoShareMsq = Instance.AutoShareMsq;
if (ToggleButtonWithHelpMarker("Auto Share MSQ", "Automatically share the Main Scenario Quest with your group when it is accepted.", ref autoShareMsq))
{
Instance.AutoShareMsq = autoShareMsq;
Save();
}
// TODO: Implement this feature
/*var autoShareNewQuests = Instance.AutoShareNewQuests;
if (ToggleButtonWithHelpMarker("Auto Share New Quests", "Automatically share new quests with your group when they are accepted.", ref autoShareNewQuests))
{
Instance.AutoShareNewQuests = autoShareNewQuests;
Save();
}*/
var hideFutureStepsHost = Instance.HideFutureStepsHost;
if (ToggleButtonWithHelpMarker("Hide Future Steps (Host)", "Hides future steps of the quest from the UI when viewing your hosted quest. This does not affect the quest sharing process.", ref hideFutureStepsHost))
{
Instance.HideFutureStepsHost = hideFutureStepsHost;
Save();
}
var hideFutureStepsMember = Instance.HideFutureStepsMember;
if (ToggleButtonWithHelpMarker("Hide Future Steps (Member)", "Hides future steps of the quest from the UI when viewing a shared quest. This does not affect the quest sharing process.", ref hideFutureStepsMember))
{
Instance.HideFutureStepsMember = hideFutureStepsMember;
Save();
}
}
private static bool ToggleButtonWithHelpMarker(string label, string helpText, ref bool v)
{
ImGui.TextUnformatted(label);
ImGui.SameLine();
var result = ImGuiComponents.ToggleButton(label, ref v);
ImGuiComponents.HelpMarker(helpText);
return result;
}
}

View File

@ -49,41 +49,61 @@ namespace QuestShare.Server.Hubs
var sessions = new List<Objects.Session>();
Objects.OwnedSession? ownedSession = null;
foreach(var share in request.ShareCodes)
foreach (var share in request.ShareCodes)
{
var session = await SessionManager.GetSession(share.Code);
if (session != null)
{
var members = await ClientManager.GetClientsInSession(session);
if (members.Any(m => m.Client.ClientId == client.ClientId))
if (session.OwnerCharacterId == share.CharacterId && client.ClientId == session.Owner.ClientId && ownedSession == null)
{
if (session.OwnerCharacterId == share.CharacterId && client.ClientId == session.Owner.ClientId && ownedSession == null)
ownedSession = new Objects.OwnedSession
{
ownedSession = new Objects.OwnedSession
{
Session = new Objects.Session {
OwnerCharacterId = session.OwnerCharacterId,
ShareCode = session.ShareCode,
ActiveQuestId = session.SharedQuestId,
ActiveQuestStep = session.SharedQuestStep
},
SkipPartyCheck = session.SkipPartyCheck,
IsActive = session.IsActive,
AllowJoins = session.AllowJoins,
};
}
else
{
sessions.Add(new Objects.Session
Session = new Objects.Session
{
OwnerCharacterId = session.OwnerCharacterId,
ShareCode = session.ShareCode,
ActiveQuestId = session.SharedQuestId,
ActiveQuestStep = session.SharedQuestStep,
ShareCode = share.Code,
});
}
ActiveQuestStep = session.SharedQuestStep
},
SkipPartyCheck = session.SkipPartyCheck,
IsActive = session.IsActive,
AllowJoins = session.AllowJoins,
};
}
else if (members.Any(m => m.Client.ClientId == client.ClientId))
{
sessions.Add(new Objects.Session
{
OwnerCharacterId = session.OwnerCharacterId,
ActiveQuestId = session.SharedQuestId,
ActiveQuestStep = session.SharedQuestStep,
ShareCode = share.Code,
});
await Groups.AddToGroupAsync(Context.ConnectionId, session.ShareCode);
}
else
{
sessions.Add(new Objects.Session
{
OwnerCharacterId = "",
ActiveQuestId = 0,
ActiveQuestStep = 0,
ShareCode = share.Code,
IsValid = false,
});
}
}
else
{
sessions.Add(new Objects.Session
{
OwnerCharacterId = "",
ActiveQuestId = 0,
ActiveQuestStep = 0,
ShareCode = share.Code,
IsValid = false,
});
}
}
await Clients.Caller.SendAsync(nameof(Authorize), new Authorize.Response

View File

@ -36,6 +36,36 @@ namespace QuestShare.Server.Hubs
});
return;
}
if (!session.AllowJoins)
{
Log.Warning($"[GroupJoin] Session {request.SessionInfo.Code} does not allow joins.");
await Clients.Caller.SendAsync(nameof(GroupJoin), new GroupJoin.Response
{
Success = false,
Error = Error.InvalidParty,
});
return;
}
if (session.Owner.ClientId == client!.ClientId)
{
Log.Warning($"[GroupJoin] Client {client} is the owner of session {session.ShareCode}");
await Clients.Caller.SendAsync(nameof(GroupJoin), new GroupJoin.Response
{
Success = false,
Error = Error.InvalidParty,
});
return;
}
if (!session.SkipPartyCheck && !session.PartyMembers.Contains(request.SessionInfo.CharacterId))
{
Log.Warning($"[GroupJoin] Client {client} is not joined to party hosted by {session.OwnerCharacterId}.");
await Clients.Caller.SendAsync(nameof(GroupJoin), new GroupJoin.Response
{
Success = false,
Error = Error.InvalidParty,
});
return;
}
await ClientManager.AddClientSession(client!.ClientId, session.SessionId);
await Groups.AddToGroupAsync(Context.ConnectionId, session.ShareCode.ToString());
await ClientManager.AddKnownShareCode(client!, session.ShareCode);
@ -46,6 +76,8 @@ namespace QuestShare.Server.Hubs
{
OwnerCharacterId = session.OwnerCharacterId,
ShareCode = session.ShareCode,
ActiveQuestId = session.SharedQuestId,
ActiveQuestStep = session.SharedQuestStep
},
});
await Clients.GroupExcept(Context.ConnectionId, session.ShareCode.ToString()).SendAsync(nameof(GroupJoin.GroupJoinBroadcast), new GroupJoin.GroupJoinBroadcast

View File

@ -36,18 +36,25 @@ namespace QuestShare.Server.Hubs
});
return;
}
await SessionManager.UpdateActiveQuest(request.Session.ShareCode, request.Session.ActiveQuestId, request.Session.ActiveQuestStep);
await SessionManager.SetPartyMembers(session!.ShareCode, [.. request.PartyMembers]);
await Clients.Caller.SendAsync(nameof(Update), new Update.Response
if (request.IsQuestUpdate)
{
Success = true,
Error = Error.None,
});
// Broadcast to party
await Clients.GroupExcept(session.ShareCode.ToString(), Context.ConnectionId).SendAsync(nameof(Update.UpdateBroadcast), new Update.UpdateBroadcast
await SessionManager.UpdateActiveQuest(request.Session.ShareCode, request.Session.ActiveQuestId, request.Session.ActiveQuestStep);
// Broadcast to party
await Clients.GroupExcept(session.ShareCode.ToString(), Context.ConnectionId).SendAsync(nameof(Update.UpdateBroadcast), new Update.UpdateBroadcast
{
Session = request.Session.Session,
});
} else
{
Session = request.Session.Session,
});
await SessionManager.SetSessionSettings(session.ShareCode, request.Session);
await Clients.Caller.SendAsync(nameof(Update), new Update.Response
{
Success = true,
Error = Error.None,
});
}
}
}
}

View File

@ -9,12 +9,12 @@ namespace QuestShare.Server.Managers
public static async Task<Session?> GetSession(Client client)
{
using var context = new QuestShareContext();
return await context.Sessions.Where(s => s.Owner.ClientId == client.ClientId).FirstOrDefaultAsync();
return await context.Sessions.Where(s => s.Owner.ClientId == client.ClientId).Include(session => session.Owner).FirstOrDefaultAsync();
}
public static async Task<Session?> GetSession(string ShareCode)
{
using var context = new QuestShareContext();
var session = await context.Sessions.Where(s => s.ShareCode == ShareCode).FirstOrDefaultAsync();
var session = await context.Sessions.Where(s => s.ShareCode == ShareCode).Include(session => session.Owner).FirstOrDefaultAsync();
return session;
}
@ -47,6 +47,7 @@ namespace QuestShare.Server.Managers
s.AllowJoins = session.AllowJoins;
s.SkipPartyCheck = session.SkipPartyCheck;
await context.SaveChangesAsync();
await AddMemberToSession(s, s.OwnerCharacterId);
return s;
}
@ -92,7 +93,7 @@ namespace QuestShare.Server.Managers
public static async Task<List<SessionMember>> GetMembersInSession(Session session)
{
using var context = new QuestShareContext();
var s = await context.SessionMembers.Where(s => s.Session.SessionId == session.SessionId).Include("Sessions").Include("Clients").ToListAsync();
var s = await context.SessionMembers.Where(s => s.Session.SessionId == session.SessionId).Include(sm => sm.Session).Include(sm => sm.Client).ToListAsync();
return s;
}
@ -111,5 +112,22 @@ namespace QuestShare.Server.Managers
var records = await context.SaveChangesAsync();
Log.Debug($"[UPDATE] Updated {records} quests for session {shareCode}");
}
public static async Task SetSessionSettings(string shareCode, Objects.OwnedSession sessionObj)
{
using var context = new QuestShareContext();
var session = await context.Sessions.Where(s => s.ShareCode == shareCode).FirstOrDefaultAsync();
if (session == null)
{
// log error
Console.Error.WriteLine($"Failed to update settings for session {shareCode}");
return;
}
session.IsActive = sessionObj.IsActive;
session.AllowJoins = sessionObj.AllowJoins;
session.SkipPartyCheck = sessionObj.SkipPartyCheck;
var records = await context.SaveChangesAsync();
Log.Debug($"[UPDATE] Updated {records} settings for session {shareCode}");
}
}
}

View File

@ -21,7 +21,9 @@ namespace QuestShare.Server.Models
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(Environment.GetEnvironmentVariable("QUESTSHARE_DATABASE"));
optionsBuilder
.UseLazyLoadingProxies()
.UseNpgsql(Environment.GetEnvironmentVariable("QUESTSHARE_DATABASE"));
}

View File

@ -13,7 +13,7 @@ namespace QuestShare.Server.Models
[Key]
public Guid SessionId { get; set; }
public string OwnerCharacterId { get; set; } = "";
public required Client Owner { get; set; }
public virtual required Client Owner { get; set; }
public required string ShareCode { get; set; }
public required string ReservedConnectionId { get; set; }
public int SharedQuestId { get; set; } = 0;

View File

@ -9,8 +9,8 @@ namespace QuestShare.Server.Models
public class SessionMember
{
public Guid ClientSessionId { get; set; }
public required Client Client { get; set; }
public required Session Session { get; set; }
public virtual required Client Client { get; set; }
public virtual required Session Session { get; set; }
// public required string CharacterId { get; set; }
public DateTime Created { get; set; }
public DateTime LastUpdated { get; set; }

View File

@ -12,7 +12,7 @@ namespace QuestShare.Server
{
public class Program
{
public static async Task Main(string[] args)
public static void Main(string[] args)
{
var log = new LoggerConfiguration()
.WriteTo.Console()

View File

@ -3,7 +3,8 @@
"http": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
"ASPNETCORE_ENVIRONMENT": "Development",
"QUESTSHARE_DATABASE": "Host=sol.nate.lan;User ID=dalamud;Password=dalamud1meteor;Database=questshare"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5087"

View File

@ -10,16 +10,17 @@
<ItemGroup>
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.3" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.3" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />

View File

@ -12,9 +12,9 @@
],
"RepoUrl": "https://git.nathanc.tech/nate/QuestShare/",
"AcceptsFeedback": false,
"DownloadLinkInstall": "https://git.nathanc.tech/nate/QuestShare/releases/download/alpha-4/latest.zip",
"DownloadLinkTesting": "https://git.nathanc.tech/nate/QuestShare/releases/download/alpha-4/latest.zip",
"DownloadLinkUpdate": "https://git.nathanc.tech/nate/QuestShare/releases/download/alpha-4/latest.zip",
"DownloadLinkInstall": "https://git.nathanc.tech/nate/QuestShare/releases/download/alpha-5/latest.zip",
"DownloadLinkTesting": "https://git.nathanc.tech/nate/QuestShare/releases/download/alpha-5/latest.zip",
"DownloadLinkUpdate": "https://git.nathanc.tech/nate/QuestShare/releases/download/alpha-5/latest.zip",
"DownloadCount": 1,
"LastUpdate": "1739859999",
"IsHide": false,
@ -22,7 +22,7 @@
"IconUrl": "",
"DalamudApiLevel": 11,
"InternalName": "QuestShare",
"AssemblyVersion": "1.0.1.0"
"AssemblyVersion": "1.0.2.0"
}
]