🎉 Initial Version
This commit is contained in:
parent
38347015b2
commit
acec50fd3d
8
RetainerTrack/Database/Player.cs
Normal file
8
RetainerTrack/Database/Player.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace RetainerTrack.Database
|
||||
{
|
||||
internal sealed class Player
|
||||
{
|
||||
public ulong Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
}
|
10
RetainerTrack/Database/Retainer.cs
Normal file
10
RetainerTrack/Database/Retainer.cs
Normal file
@ -0,0 +1,10 @@
|
||||
namespace RetainerTrack.Database
|
||||
{
|
||||
internal sealed class Retainer
|
||||
{
|
||||
public ulong Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public ushort WorldId { get; set; }
|
||||
public ulong OwnerContentId { get; set; }
|
||||
}
|
||||
}
|
23
RetainerTrack/Handlers/ContentIdToName.cs
Normal file
23
RetainerTrack/Handlers/ContentIdToName.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace RetainerTrack.Handlers
|
||||
{
|
||||
internal sealed class ContentIdToName
|
||||
{
|
||||
public ulong ContentId { get; init; }
|
||||
public string PlayerName { get; init; } = string.Empty;
|
||||
|
||||
public static unsafe ContentIdToName Read(nint dataPtr)
|
||||
{
|
||||
using UnmanagedMemoryStream input = new UnmanagedMemoryStream((byte*)dataPtr.ToPointer(), 40);
|
||||
using BinaryReader binaryReader = new BinaryReader(input);
|
||||
|
||||
return new ContentIdToName
|
||||
{
|
||||
ContentId = binaryReader.ReadUInt64(),
|
||||
PlayerName = Encoding.UTF8.GetString(binaryReader.ReadBytes(32)).TrimEnd(char.MinValue)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
101
RetainerTrack/Handlers/MarketBoardUIHandler.cs
Normal file
101
RetainerTrack/Handlers/MarketBoardUIHandler.cs
Normal file
@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using Dalamud.Game;
|
||||
using Dalamud.Game.Gui;
|
||||
using Dalamud.Hooking;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace RetainerTrack.Handlers
|
||||
{
|
||||
internal sealed unsafe class MarketBoardUiHandler : IDisposable
|
||||
{
|
||||
private readonly ILogger<MarketBoardUiHandler> _logger;
|
||||
private readonly Framework _framework;
|
||||
private readonly GameGui _gameGui;
|
||||
private readonly PersistenceContext _persistenceContext;
|
||||
|
||||
private AddonItemSearchResult* _itemSearchResultAddon;
|
||||
private Hook<Draw>? _drawHook;
|
||||
|
||||
private delegate void Draw(AtkUnitBase* addon);
|
||||
|
||||
public MarketBoardUiHandler(
|
||||
ILogger<MarketBoardUiHandler> logger,
|
||||
Framework framework,
|
||||
GameGui gameGui,
|
||||
PersistenceContext persistenceContext)
|
||||
{
|
||||
_logger = logger;
|
||||
_framework = framework;
|
||||
_gameGui = gameGui;
|
||||
_persistenceContext = persistenceContext;
|
||||
;
|
||||
|
||||
_framework.Update += FrameworkUpdate;
|
||||
}
|
||||
|
||||
private void FrameworkUpdate(Framework framework)
|
||||
{
|
||||
_itemSearchResultAddon = (AddonItemSearchResult*)_gameGui.GetAddonByName("ItemSearchResult");
|
||||
if (_itemSearchResultAddon == null)
|
||||
return;
|
||||
|
||||
_drawHook ??= Hook<Draw>.FromAddress(
|
||||
new nint(_itemSearchResultAddon->AtkUnitBase.AtkEventListener.vfunc[42]),
|
||||
AddonDraw);
|
||||
_drawHook.Enable();
|
||||
_framework.Update -= FrameworkUpdate;
|
||||
}
|
||||
|
||||
private void AddonDraw(AtkUnitBase* addon)
|
||||
{
|
||||
UpdateRetainerNames();
|
||||
_drawHook!.Original(addon);
|
||||
}
|
||||
|
||||
private void UpdateRetainerNames()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_itemSearchResultAddon == null || !_itemSearchResultAddon->AtkUnitBase.IsVisible)
|
||||
return;
|
||||
|
||||
var results = _itemSearchResultAddon->Results;
|
||||
if (results == null)
|
||||
return;
|
||||
|
||||
int length = results->ListLength;
|
||||
if (length == 0)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < length; ++i)
|
||||
{
|
||||
var listItem = results->ItemRendererList[i].AtkComponentListItemRenderer;
|
||||
var uldManager = listItem->AtkComponentButton.AtkComponentBase.UldManager;
|
||||
if (uldManager.NodeListCount < 14)
|
||||
continue;
|
||||
|
||||
var retainerNameNode = (AtkTextNode*)uldManager.NodeList[5];
|
||||
string retainerName = retainerNameNode->NodeText.ToString();
|
||||
if (!retainerName.Contains('('))
|
||||
{
|
||||
string playerName = _persistenceContext.GetCharacterNameOnCurrentWorld(retainerName);
|
||||
if (!string.IsNullOrEmpty(playerName))
|
||||
retainerNameNode->SetText($"{playerName} ({retainerName})");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogInformation(e, "Market board draw failed");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_drawHook?.Dispose();
|
||||
_framework.Update -= FrameworkUpdate;
|
||||
}
|
||||
}
|
||||
}
|
78
RetainerTrack/Handlers/NetworkHandler.cs
Normal file
78
RetainerTrack/Handlers/NetworkHandler.cs
Normal file
@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Dalamud.Data;
|
||||
using Dalamud.Game.ClientState;
|
||||
using Dalamud.Game.Network;
|
||||
using Dalamud.Game.Network.Structures;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Framework = FFXIVClientStructs.FFXIV.Client.System.Framework.Framework;
|
||||
|
||||
namespace RetainerTrack.Handlers
|
||||
{
|
||||
internal sealed class NetworkHandler : IDisposable
|
||||
{
|
||||
private readonly ILogger<NetworkHandler> _logger;
|
||||
private readonly GameNetwork _gameNetwork;
|
||||
private readonly DataManager _dataManager;
|
||||
private readonly ClientState _clientState;
|
||||
private readonly PersistenceContext _persistenceContext;
|
||||
|
||||
private readonly ushort? _contentIdMappingOpCode;
|
||||
|
||||
public unsafe NetworkHandler(
|
||||
ILogger<NetworkHandler> logger,
|
||||
GameNetwork gameNetwork,
|
||||
DataManager dataManager,
|
||||
ClientState clientState,
|
||||
PersistenceContext persistenceContext)
|
||||
{
|
||||
_logger = logger;
|
||||
_gameNetwork = gameNetwork;
|
||||
_dataManager = dataManager;
|
||||
_clientState = clientState;
|
||||
_persistenceContext = persistenceContext;
|
||||
|
||||
if (Framework.Instance()->GameVersion.Base == "2023.02.03.0000.0000")
|
||||
_contentIdMappingOpCode = 0x01C4;
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Not tracking content id mappings, unsupported game version {Version}",
|
||||
Framework.Instance()->GameVersion.Base);
|
||||
}
|
||||
|
||||
_gameNetwork.NetworkMessage += NetworkMessage;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_gameNetwork.NetworkMessage -= NetworkMessage;
|
||||
}
|
||||
|
||||
private void NetworkMessage(nint dataPtr, ushort opcode, uint sourceActorId, uint targetActorId,
|
||||
NetworkMessageDirection direction)
|
||||
{
|
||||
if (direction != NetworkMessageDirection.ZoneDown || !_dataManager.IsDataReady)
|
||||
return;
|
||||
|
||||
if (opcode == _dataManager.ServerOpCodes["MarketBoardOfferings"])
|
||||
{
|
||||
ushort worldId = (ushort?)_clientState.LocalPlayer?.CurrentWorld.Id ?? 0;
|
||||
if (worldId == 0)
|
||||
{
|
||||
_logger.LogInformation("Skipping market board handler, current world unknown");
|
||||
return;
|
||||
}
|
||||
|
||||
var listings = MarketBoardCurrentOfferings.Read(dataPtr);
|
||||
Task.Run(() => _persistenceContext.HandleMarketBoardPage(listings, worldId));
|
||||
}
|
||||
else if (opcode == _contentIdMappingOpCode)
|
||||
{
|
||||
var mapping = ContentIdToName.Read(dataPtr);
|
||||
_logger.LogTrace("Content id {ContentId} belongs to player '{Name}'", mapping.ContentId,
|
||||
!string.IsNullOrEmpty(mapping.PlayerName) ? mapping.PlayerName : "<unknown>");
|
||||
Task.Run(() => _persistenceContext.HandleContentIdMapping(mapping));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
66
RetainerTrack/Handlers/PartyHandler.cs
Normal file
66
RetainerTrack/Handlers/PartyHandler.cs
Normal file
@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Dalamud.Game;
|
||||
using Dalamud.Game.ClientState;
|
||||
using Dalamud.Memory;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Group;
|
||||
|
||||
namespace RetainerTrack.Handlers
|
||||
{
|
||||
internal sealed class PartyHandler : IDisposable
|
||||
{
|
||||
private readonly Framework _framework;
|
||||
private readonly ClientState _clientState;
|
||||
private readonly PersistenceContext _persistenceContext;
|
||||
|
||||
private long _lastUpdate = 0;
|
||||
|
||||
public PartyHandler(Framework framework, ClientState clientState, PersistenceContext persistenceContext)
|
||||
{
|
||||
_framework = framework;
|
||||
_clientState = clientState;
|
||||
_persistenceContext = persistenceContext;
|
||||
|
||||
_framework.Update += FrameworkUpdate;
|
||||
}
|
||||
|
||||
private unsafe void FrameworkUpdate(Framework _)
|
||||
{
|
||||
long now = Environment.TickCount64;
|
||||
if (!_clientState.IsLoggedIn || _clientState.IsPvPExcludingDen || now - _lastUpdate < 180_000)
|
||||
return;
|
||||
|
||||
_lastUpdate = now;
|
||||
List<ContentIdToName> mappings = new();
|
||||
|
||||
var groupManager = GroupManager.Instance();
|
||||
foreach (var partyMember in groupManager->PartyMembersSpan)
|
||||
HandlePartyMember(partyMember, mappings);
|
||||
|
||||
foreach (var allianceMember in groupManager->AllianceMembersSpan)
|
||||
HandlePartyMember(allianceMember, mappings);
|
||||
|
||||
if (mappings.Count > 0)
|
||||
Task.Run(() => _persistenceContext.HandleContentIdMapping(mappings));
|
||||
}
|
||||
|
||||
private unsafe void HandlePartyMember(PartyMember partyMember, List<ContentIdToName> contentIdToNames)
|
||||
{
|
||||
if (partyMember.ContentID == 0)
|
||||
return;
|
||||
|
||||
string name = MemoryHelper.ReadStringNullTerminated((nint)partyMember.Name);
|
||||
contentIdToNames.Add(new ContentIdToName
|
||||
{
|
||||
ContentId = (ulong)partyMember.ContentID,
|
||||
PlayerName = name,
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_framework.Update -= FrameworkUpdate;
|
||||
}
|
||||
}
|
||||
}
|
121
RetainerTrack/Handlers/PersistenceContext.cs
Normal file
121
RetainerTrack/Handlers/PersistenceContext.cs
Normal file
@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Dalamud.Game.ClientState;
|
||||
using Dalamud.Game.Network.Structures;
|
||||
using LiteDB;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RetainerTrack.Database;
|
||||
|
||||
namespace RetainerTrack.Handlers
|
||||
{
|
||||
internal sealed class PersistenceContext
|
||||
{
|
||||
private readonly ILogger<PersistenceContext> _logger;
|
||||
private readonly ClientState _clientState;
|
||||
private readonly LiteDatabase _liteDatabase;
|
||||
private readonly ConcurrentDictionary<uint, ConcurrentDictionary<string, ulong>> _worldRetainerCache = new();
|
||||
private readonly ConcurrentDictionary<ulong, string> _playerNameCache = new();
|
||||
|
||||
public PersistenceContext(ILogger<PersistenceContext> logger, ClientState clientState,
|
||||
LiteDatabase liteDatabase)
|
||||
{
|
||||
_logger = logger;
|
||||
_clientState = clientState;
|
||||
_liteDatabase = liteDatabase;
|
||||
|
||||
var retainersByWorld = _liteDatabase.GetCollection<Retainer>().FindAll()
|
||||
.GroupBy(r => r.WorldId);
|
||||
foreach (var retainers in retainersByWorld)
|
||||
{
|
||||
var world = _worldRetainerCache.GetOrAdd(retainers.Key, _ => new());
|
||||
foreach (var retainer in retainers)
|
||||
{
|
||||
if (retainer.Name != null)
|
||||
world[retainer.Name] = retainer.OwnerContentId;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var player in _liteDatabase.GetCollection<Player>().FindAll())
|
||||
_playerNameCache[player.Id] = player.Name ?? string.Empty;
|
||||
}
|
||||
|
||||
public string GetCharacterNameOnCurrentWorld(string retainerName)
|
||||
{
|
||||
uint currentWorld = _clientState.LocalPlayer?.CurrentWorld.Id ?? 0;
|
||||
if (currentWorld == 0)
|
||||
return string.Empty;
|
||||
|
||||
var currentWorldCache = _worldRetainerCache.GetOrAdd(currentWorld, _ => new());
|
||||
if (!currentWorldCache.TryGetValue(retainerName, out ulong playerContentId))
|
||||
return string.Empty;
|
||||
|
||||
return _playerNameCache.TryGetValue(playerContentId, out string? playerName) ? playerName : string.Empty;
|
||||
}
|
||||
|
||||
public void HandleMarketBoardPage(MarketBoardCurrentOfferings listings, ushort worldId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var updates =
|
||||
listings.ItemListings.DistinctBy(o => o.RetainerId)
|
||||
.Where(l => l.RetainerId != 0)
|
||||
.Where(l => l.RetainerOwnerId != 0)
|
||||
.Select(l =>
|
||||
new Retainer
|
||||
{
|
||||
Id = l.RetainerId,
|
||||
Name = l.RetainerName,
|
||||
WorldId = worldId,
|
||||
OwnerContentId = l.RetainerOwnerId,
|
||||
})
|
||||
.ToList();
|
||||
_liteDatabase.GetCollection<Retainer>().Upsert(updates);
|
||||
foreach (var retainer in updates)
|
||||
{
|
||||
if (!_playerNameCache.TryGetValue(retainer.OwnerContentId, out string? ownerName))
|
||||
ownerName = retainer.OwnerContentId.ToString();
|
||||
_logger.LogTrace("Retainer {RetainerName} belongs to {OwnerId}", retainer.Name,
|
||||
ownerName);
|
||||
|
||||
if (retainer.Name != null)
|
||||
{
|
||||
var world = _worldRetainerCache.GetOrAdd(retainer.WorldId, _ => new());
|
||||
world[retainer.Name] = retainer.OwnerContentId;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Could not persist retainer info from market board page");
|
||||
}
|
||||
}
|
||||
|
||||
public void HandleContentIdMapping(ContentIdToName mapping)
|
||||
=> HandleContentIdMapping(new List<ContentIdToName> { mapping });
|
||||
|
||||
public void HandleContentIdMapping(IReadOnlyList<ContentIdToName> mappings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var updates = mappings
|
||||
.Where(mapping => mapping.ContentId != 0 && !string.IsNullOrEmpty(mapping.PlayerName))
|
||||
.Select(mapping =>
|
||||
new Player
|
||||
{
|
||||
Id = mapping.ContentId,
|
||||
Name = mapping.PlayerName,
|
||||
})
|
||||
.ToList();
|
||||
_liteDatabase.GetCollection<Player>().Upsert(updates);
|
||||
foreach (var player in updates)
|
||||
_playerNameCache[player.Id] = player.Name ?? string.Empty;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Could not persist multiple mappings");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -19,7 +19,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dalamud.Extensions.MicrosoftLogging" Version="1.0.0"/>
|
||||
<PackageReference Include="DalamudPackager" Version="2.1.10"/>
|
||||
<PackageReference Include="LiteDB" Version="5.0.15"/>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -1,13 +1,75 @@
|
||||
using Dalamud.Plugin;
|
||||
using System.IO;
|
||||
using Dalamud.Data;
|
||||
using Dalamud.Extensions.MicrosoftLogging;
|
||||
using Dalamud.Game;
|
||||
using Dalamud.Game.ClientState;
|
||||
using Dalamud.Game.Gui;
|
||||
using Dalamud.Game.Network;
|
||||
using Dalamud.Plugin;
|
||||
using LiteDB;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RetainerTrack.Database;
|
||||
using RetainerTrack.Handlers;
|
||||
|
||||
namespace RetainerTrack
|
||||
{
|
||||
public class RetainerTrackPlugin : IDalamudPlugin
|
||||
// ReSharper disable once UnusedType.Global
|
||||
internal sealed class RetainerTrackPlugin : IDalamudPlugin
|
||||
{
|
||||
private readonly ServiceProvider? _serviceProvider;
|
||||
|
||||
public string Name => "RetainerTrack";
|
||||
|
||||
public RetainerTrackPlugin(
|
||||
DalamudPluginInterface pluginInterface,
|
||||
GameNetwork gameNetwork,
|
||||
DataManager dataManager,
|
||||
Framework framework,
|
||||
ClientState clientState,
|
||||
GameGui gameGui)
|
||||
{
|
||||
ServiceCollection serviceCollection = new();
|
||||
serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Trace)
|
||||
.ClearProviders()
|
||||
.AddDalamudLogger(this));
|
||||
serviceCollection.AddSingleton<IDalamudPlugin>(this);
|
||||
serviceCollection.AddSingleton(pluginInterface);
|
||||
serviceCollection.AddSingleton(gameNetwork);
|
||||
serviceCollection.AddSingleton(dataManager);
|
||||
serviceCollection.AddSingleton(framework);
|
||||
serviceCollection.AddSingleton(clientState);
|
||||
serviceCollection.AddSingleton(gameGui);
|
||||
|
||||
serviceCollection.AddSingleton<LiteDatabase>(_ =>
|
||||
new LiteDatabase(new ConnectionString
|
||||
{
|
||||
Filename = Path.Join(pluginInterface.GetPluginConfigDirectory(), "retainer-data.litedb"),
|
||||
Connection = ConnectionType.Direct,
|
||||
Upgrade = true,
|
||||
}));
|
||||
|
||||
serviceCollection.AddSingleton<PersistenceContext>();
|
||||
serviceCollection.AddSingleton<NetworkHandler>();
|
||||
serviceCollection.AddSingleton<PartyHandler>();
|
||||
serviceCollection.AddSingleton<MarketBoardUiHandler>();
|
||||
|
||||
_serviceProvider = serviceCollection.BuildServiceProvider();
|
||||
|
||||
LiteDatabase liteDatabase = _serviceProvider.GetRequiredService<LiteDatabase>();
|
||||
liteDatabase.GetCollection<Retainer>()
|
||||
.EnsureIndex(x => x.Id);
|
||||
liteDatabase.GetCollection<Player>()
|
||||
.EnsureIndex(x => x.Id);
|
||||
|
||||
_serviceProvider.GetRequiredService<PartyHandler>();
|
||||
_serviceProvider.GetRequiredService<NetworkHandler>();
|
||||
_serviceProvider.GetRequiredService<MarketBoardUiHandler>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_serviceProvider?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user