This commit is contained in:
Liza 2024-07-03 01:03:00 +02:00
parent 6bdaca7650
commit 65c0bec80e
Signed by: liza
GPG Key ID: 7199F8D727D55F67
20 changed files with 450 additions and 216 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "LLib"]
path = LLib
url = https://git.carvel.li/liza/LLib

1
LLib Submodule

@ -0,0 +1 @@
Subproject commit 7027d291efbbff6a55944dd521d3907210ddecbe

View File

@ -2,6 +2,8 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RetainerTrack", "RetainerTrack\RetainerTrack.csproj", "{5FA75994-45B8-4E1A-A9D6-F28CD4C52342}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LLib", "LLib\LLib.csproj", "{3095F0BF-355A-4D13-BDDE-CCB4CA48D3C3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -12,5 +14,9 @@ Global
{5FA75994-45B8-4E1A-A9D6-F28CD4C52342}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5FA75994-45B8-4E1A-A9D6-F28CD4C52342}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5FA75994-45B8-4E1A-A9D6-F28CD4C52342}.Release|Any CPU.Build.0 = Release|Any CPU
{3095F0BF-355A-4D13-BDDE-CCB4CA48D3C3}.Debug|Any CPU.ActiveCfg = Debug|x64
{3095F0BF-355A-4D13-BDDE-CCB4CA48D3C3}.Debug|Any CPU.Build.0 = Debug|x64
{3095F0BF-355A-4D13-BDDE-CCB4CA48D3C3}.Release|Any CPU.ActiveCfg = Debug|x64
{3095F0BF-355A-4D13-BDDE-CCB4CA48D3C3}.Release|Any CPU.Build.0 = Debug|x64
EndGlobalSection
EndGlobal

View File

@ -28,6 +28,14 @@ namespace RetainerTrack.Database.Compiled
sentinel: 0ul);
localContentId.TypeMapping = SqliteULongTypeMapping.Default;
var accountId = runtimeEntityType.AddProperty(
"AccountId",
typeof(ulong?),
propertyInfo: typeof(Player).GetProperty("AccountId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Player).GetField("<AccountId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
accountId.TypeMapping = SqliteULongTypeMapping.Default;
var name = runtimeEntityType.AddProperty(
"Name",
typeof(string),

View File

@ -34,6 +34,11 @@ namespace RetainerTrack.Database.Compiled
var defaultTableMappings = new List<TableMappingBase<ColumnMappingBase>>();
player.SetRuntimeAnnotation("Relational:DefaultMappings", defaultTableMappings);
var retainerTrackDatabasePlayerTableBase = new TableBase("RetainerTrack.Database.Player", null, relationalModel);
var accountIdColumnBase = new ColumnBase<ColumnMappingBase>("AccountId", "INTEGER", retainerTrackDatabasePlayerTableBase)
{
IsNullable = true
};
retainerTrackDatabasePlayerTableBase.Columns.Add("AccountId", accountIdColumnBase);
var localContentIdColumnBase = new ColumnBase<ColumnMappingBase>("LocalContentId", "INTEGER", retainerTrackDatabasePlayerTableBase);
retainerTrackDatabasePlayerTableBase.Columns.Add("LocalContentId", localContentIdColumnBase);
var nameColumnBase = new ColumnBase<ColumnMappingBase>("Name", "TEXT", retainerTrackDatabasePlayerTableBase);
@ -43,6 +48,7 @@ namespace RetainerTrack.Database.Compiled
retainerTrackDatabasePlayerTableBase.AddTypeMapping(retainerTrackDatabasePlayerMappingBase, false);
defaultTableMappings.Add(retainerTrackDatabasePlayerMappingBase);
RelationalModel.CreateColumnMapping((ColumnBase<ColumnMappingBase>)localContentIdColumnBase, player.FindProperty("LocalContentId")!, retainerTrackDatabasePlayerMappingBase);
RelationalModel.CreateColumnMapping((ColumnBase<ColumnMappingBase>)accountIdColumnBase, player.FindProperty("AccountId")!, retainerTrackDatabasePlayerMappingBase);
RelationalModel.CreateColumnMapping((ColumnBase<ColumnMappingBase>)nameColumnBase, player.FindProperty("Name")!, retainerTrackDatabasePlayerMappingBase);
var tableMappings = new List<TableMapping>();
@ -50,6 +56,11 @@ namespace RetainerTrack.Database.Compiled
var playersTable = new Table("Players", null, relationalModel);
var localContentIdColumn = new Column("LocalContentId", "INTEGER", playersTable);
playersTable.Columns.Add("LocalContentId", localContentIdColumn);
var accountIdColumn = new Column("AccountId", "INTEGER", playersTable)
{
IsNullable = true
};
playersTable.Columns.Add("AccountId", accountIdColumn);
var nameColumn = new Column("Name", "TEXT", playersTable);
playersTable.Columns.Add("Name", nameColumn);
var pK_Players = new UniqueConstraint("PK_Players", playersTable, new[] { localContentIdColumn });
@ -65,6 +76,7 @@ namespace RetainerTrack.Database.Compiled
playersTable.AddTypeMapping(playersTableMapping, false);
tableMappings.Add(playersTableMapping);
RelationalModel.CreateColumnMapping(localContentIdColumn, player.FindProperty("LocalContentId")!, playersTableMapping);
RelationalModel.CreateColumnMapping(accountIdColumn, player.FindProperty("AccountId")!, playersTableMapping);
RelationalModel.CreateColumnMapping(nameColumn, player.FindProperty("Name")!, playersTableMapping);
var retainer = FindEntityType("RetainerTrack.Database.Retainer")!;

View File

@ -0,0 +1,66 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RetainerTrack.Database;
#nullable disable
namespace RetainerTrack.Database.Migrations
{
[DbContext(typeof(RetainerTrackContext))]
[Migration("20240629073047_AddAccountIdToPlayer")]
partial class AddAccountIdToPlayer
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.5");
modelBuilder.Entity("RetainerTrack.Database.Player", b =>
{
b.Property<ulong>("LocalContentId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ulong?>("AccountId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.HasKey("LocalContentId");
b.ToTable("Players");
});
modelBuilder.Entity("RetainerTrack.Database.Retainer", b =>
{
b.Property<ulong>("LocalContentId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.Property<ulong>("OwnerLocalContentId")
.HasColumnType("INTEGER");
b.Property<ushort>("WorldId")
.HasColumnType("INTEGER");
b.HasKey("LocalContentId");
b.ToTable("Retainers");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RetainerTrack.Database.Migrations
{
/// <inheritdoc />
public partial class AddAccountIdToPlayer : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<ulong>(
name: "AccountId",
table: "Players",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AccountId",
table: "Players");
}
}
}

View File

@ -1,4 +1,5 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@ -22,6 +23,9 @@ namespace RetainerTrack.Database.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ulong?>("AccountId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(20)

View File

@ -9,4 +9,6 @@ public class Player
[MaxLength(20), Required]
public string? Name { get; set; }
public ulong? AccountId { get; set; }
}

View File

@ -1,7 +0,0 @@
namespace RetainerTrack.Handlers;
internal sealed class ContentIdToName
{
public ulong ContentId { get; init; }
public string PlayerName { get; init; } = string.Empty;
}

View File

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Dalamud.Hooking;
using Dalamud.Memory;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using Dalamud.Utility.Signatures;
using Microsoft.Extensions.Logging;
@ -36,7 +37,8 @@ internal sealed unsafe class GameHooks : IDisposable
#pragma warning restore CS0649
public GameHooks(ILogger<GameHooks> logger, PersistenceContext persistenceContext, IGameInteropProvider gameInteropProvider)
public GameHooks(ILogger<GameHooks> logger, PersistenceContext persistenceContext,
IGameInteropProvider gameInteropProvider)
{
_logger = logger;
_persistenceContext = persistenceContext;
@ -53,9 +55,10 @@ internal sealed unsafe class GameHooks : IDisposable
{
try
{
var mapping = new ContentIdToName
var mapping = new PlayerMapping
{
ContentId = contentId,
AccountId = null,
PlayerName = MemoryHelper.ReadString(new nint(playerName), Encoding.ASCII, 32),
};
@ -63,7 +66,8 @@ internal sealed unsafe class GameHooks : IDisposable
{
_logger.LogTrace("Content id {ContentId} belongs to '{Name}'", mapping.ContentId,
mapping.PlayerName);
Task.Run(() => _persistenceContext.HandleContentIdMapping(mapping));
if (mapping.PlayerName.IsValidCharacterName())
Task.Run(() => _persistenceContext.HandleContentIdMapping(new List<PlayerMapping> { mapping }));
}
else
{
@ -84,19 +88,23 @@ internal sealed unsafe class GameHooks : IDisposable
try
{
var result = Marshal.PtrToStructure<SocialListResultPage>(dataPtr);
List<ContentIdToName> mappings = new();
List<PlayerMapping> mappings = new();
foreach (SocialListPlayer player in result.PlayerSpan)
{
var mapping = new ContentIdToName
if (player.ContentId == 0)
continue;
var mapping = new PlayerMapping
{
ContentId = player.ContentId,
AccountId = player.AccountId != 0 ? player.AccountId : null,
PlayerName = MemoryHelper.ReadString(new nint(player.CharacterName), Encoding.ASCII, 32),
};
if (!string.IsNullOrEmpty(mapping.PlayerName))
{
_logger.LogDebug("Content id {ContentId} belongs to '{Name}'", mapping.ContentId,
mapping.PlayerName);
_logger.LogDebug("Content id {ContentId} belongs to '{Name}' ({AccountId})", mapping.ContentId,
mapping.PlayerName, mapping.AccountId);
mappings.Add(mapping);
}
else
@ -139,12 +147,12 @@ internal sealed unsafe class GameHooks : IDisposable
[StructLayout(LayoutKind.Explicit, Size = 0x420)]
internal struct SocialListResultPage
{
[FieldOffset(0x10)] private fixed byte Players[10 * 0x58];
[FieldOffset(0x10)] private fixed byte Players[10 * 0x70];
public Span<SocialListPlayer> PlayerSpan => new(Unsafe.AsPointer(ref Players[0]), 10);
}
[StructLayout(LayoutKind.Explicit, Size = 0x68, Pack = 1)]
[StructLayout(LayoutKind.Explicit, Size = 0x70, Pack = 1)]
internal struct SocialListPlayer
{
/// <summary>
@ -153,9 +161,14 @@ internal sealed unsafe class GameHooks : IDisposable
/// </summary>
[FieldOffset(0x00)] public readonly ulong ContentId;
/// <summary>
/// Only seems to be set for certain kind of social lists, e.g. friend list/FC members doesn't include any.
/// </summary>
[FieldOffset(0x18)] public readonly ulong AccountId;
/// <summary>
/// This *can* be empty, e.g. if you're querying your friend list, the names are ONLY set for characters on the same world.
/// </summary>
[FieldOffset(0x3C)] public fixed byte CharacterName[32];
[FieldOffset(0x44)] public fixed byte CharacterName[32];
}
}

View File

@ -1,64 +1,38 @@
using System;
using System.Threading.Tasks;
using Dalamud.Game.Network.Structures;
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.UI.Info;
using Microsoft.Extensions.Logging;
namespace RetainerTrack.Handlers;
internal sealed class MarketBoardOfferingsHandler : IDisposable
{
private unsafe delegate void* MarketBoardOfferings(InfoProxyItemSearch* a1, nint packetData);
private readonly IMarketBoard _marketBoard;
private readonly ILogger<MarketBoardOfferingsHandler> _logger;
private readonly IClientState _clientState;
private readonly PersistenceContext _persistenceContext;
private readonly Hook<MarketBoardOfferings> _marketBoardOfferingsHook;
public unsafe MarketBoardOfferingsHandler(
public MarketBoardOfferingsHandler(
IMarketBoard marketBoard,
ILogger<MarketBoardOfferingsHandler> logger,
IClientState clientState,
IGameInteropProvider gameInteropProvider,
PersistenceContext persistenceContext)
{
_marketBoard = marketBoard;
_logger = logger;
_clientState = clientState;
_persistenceContext = persistenceContext;
_logger.LogDebug("Setting up offerings hook");
_marketBoardOfferingsHook =
gameInteropProvider.HookFromSignature<MarketBoardOfferings>("48 89 5C 24 ?? 57 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 0F B6 82 ?? ?? ?? ?? 48 8B FA 48 8B D9 38 41 19 74 54",
MarketBoardOfferingsDetour);
_marketBoardOfferingsHook.Enable();
_logger.LogDebug("Offerings hook enabled successfully");
_marketBoard.OfferingsReceived += HandleOfferings;
}
public void Dispose()
{
_marketBoardOfferingsHook.Dispose();
_marketBoard.OfferingsReceived += HandleOfferings;
}
// adapted from https://github.com/tesu/PennyPincher/commit/0f9b3963fd4a6e9b87f585ee491d4de59a93f7a3
private unsafe void* MarketBoardOfferingsDetour(InfoProxyItemSearch* a1, nint packetData)
{
try
{
if (packetData != nint.Zero)
{
ParseOfferings(packetData);
}
}
catch (Exception e)
{
_logger.LogError(e, "Could not parse marketboard offerings.");
}
return _marketBoardOfferingsHook.Original(a1, packetData);
}
private void ParseOfferings(nint dataPtr)
private void HandleOfferings(IMarketBoardCurrentOfferings currentOfferings)
{
ushort worldId = (ushort?)_clientState.LocalPlayer?.CurrentWorld.Id ?? 0;
if (worldId == 0)
@ -67,7 +41,6 @@ internal sealed class MarketBoardOfferingsHandler : IDisposable
return;
}
var listings = MarketBoardCurrentOfferings.Read(dataPtr);
Task.Run(() => _persistenceContext.HandleMarketBoardPage(listings, worldId));
Task.Run(() => _persistenceContext.HandleMarketBoardPage(currentOfferings, worldId));
}
}

View File

@ -51,11 +51,17 @@ internal sealed unsafe class MarketBoardUiHandler : IDisposable
for (int i = 0; i < length; ++i)
{
var listItem = results->ItemRendererList[i].AtkComponentListItemRenderer;
if (listItem == null)
return;
var uldManager = listItem->AtkComponentButton.AtkComponentBase.UldManager;
if (uldManager.NodeListCount < 14)
continue;
var retainerNameNode = (AtkTextNode*)uldManager.NodeList[5];
if (retainerNameNode == null)
return;
string retainerName = retainerNameNode->NodeText.ToString();
if (!retainerName.Contains('(', StringComparison.Ordinal))
{

View File

@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Memory;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using Microsoft.Extensions.Logging;
namespace RetainerTrack.Handlers;
internal sealed class ObjectTableHandler : IDisposable
{
private readonly IObjectTable _objectTable;
private readonly IFramework _framework;
private readonly IClientState _clientState;
private readonly ILogger<ObjectTableHandler> _logger;
private readonly PersistenceContext _persistenceContext;
private long _lastUpdate;
public ObjectTableHandler(IObjectTable objectTable, IFramework framework, IClientState clientState, ILogger<ObjectTableHandler> logger, PersistenceContext persistenceContext)
{
_objectTable = objectTable;
_framework = framework;
_clientState = clientState;
_logger = logger;
_persistenceContext = persistenceContext;
_framework.Update += FrameworkUpdate;
}
private unsafe void FrameworkUpdate(IFramework framework)
{
long now = Environment.TickCount64;
if (!_clientState.IsLoggedIn || _clientState.IsPvPExcludingDen || now - _lastUpdate < 30_000)
return;
_lastUpdate = now;
List<PlayerMapping> playerMappings = new();
foreach (var obj in _objectTable)
{
if (obj.ObjectKind == ObjectKind.Player)
{
var bc = (BattleChara*)obj.Address;
var ep = (ExtendedPlayer*)obj.Address;
if (ep->ContentId == 0 || ep->AccountId == 0)
continue;
playerMappings.Add(new PlayerMapping
{
ContentId = ep->ContentId,
AccountId = ep->AccountId,
PlayerName = bc->NameString,
});
}
}
if (playerMappings.Count > 0)
_persistenceContext.HandleContentIdMapping(playerMappings);
}
public void Dispose()
{
_framework.Update -= FrameworkUpdate;
}
[StructLayout(LayoutKind.Explicit, Size = 0x2280)]
public struct ExtendedPlayer
{
[FieldOffset(0x2258)] public ulong AccountId;
[FieldOffset(0x2260)] public ulong ContentId;
}
}

View File

@ -1,68 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Dalamud.Memory;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Group;
namespace RetainerTrack.Handlers;
internal sealed class PartyHandler : IDisposable
{
private readonly IFramework _framework;
private readonly IClientState _clientState;
private readonly PersistenceContext _persistenceContext;
private long _lastUpdate;
public PartyHandler(IFramework framework, IClientState clientState, PersistenceContext persistenceContext)
{
_framework = framework;
_clientState = clientState;
_persistenceContext = persistenceContext;
_framework.Update += FrameworkUpdate;
}
private unsafe void FrameworkUpdate(IFramework _)
{
long now = Environment.TickCount64;
if (!_clientState.IsLoggedIn || _clientState.IsPvPExcludingDen || now - _lastUpdate < 180_000)
return;
_lastUpdate = now;
// skip if we're not in an alliance, party members are handled via social list updates
var groupManager = GroupManager.Instance();
if (groupManager->AllianceFlags == 0x0)
return;
List<ContentIdToName> mappings = new();
foreach (var allianceMember in groupManager->AllianceMembersSpan)
HandlePartyMember(allianceMember, mappings);
if (mappings.Count > 0)
Task.Run(() => _persistenceContext.HandleContentIdMapping(mappings));
}
private static unsafe void HandlePartyMember(PartyMember partyMember, List<ContentIdToName> contentIdToNames)
{
if (partyMember.ContentID == 0)
return;
string name = MemoryHelper.ReadStringNullTerminated((nint)partyMember.Name);
if (string.IsNullOrEmpty(name))
return;
contentIdToNames.Add(new ContentIdToName
{
ContentId = (ulong)partyMember.ContentID,
PlayerName = name,
});
}
public void Dispose()
{
_framework.Update -= FrameworkUpdate;
}
}

View File

@ -5,6 +5,7 @@ using System.Globalization;
using System.Linq;
using Dalamud.Game.Network.Structures;
using Dalamud.Plugin.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using RetainerTrack.Database;
@ -17,7 +18,7 @@ internal sealed class PersistenceContext
private readonly IClientState _clientState;
private readonly IServiceProvider _serviceProvider;
private readonly ConcurrentDictionary<uint, ConcurrentDictionary<string, ulong>> _worldRetainerCache = new();
private readonly ConcurrentDictionary<ulong, string> _playerNameCache = new();
private readonly ConcurrentDictionary<ulong, CachedPlayer> _playerCache = new();
public PersistenceContext(ILogger<PersistenceContext> logger, IClientState clientState,
IServiceProvider serviceProvider)
@ -42,7 +43,13 @@ internal sealed class PersistenceContext
}
foreach (var player in dbContext.Players)
_playerNameCache[player.LocalContentId] = player.Name ?? string.Empty;
{
_playerCache[player.LocalContentId] = new CachedPlayer
{
AccountId = player.AccountId,
Name = player.Name ?? string.Empty,
};
}
}
}
@ -56,7 +63,9 @@ internal sealed class PersistenceContext
if (!currentWorldCache.TryGetValue(retainerName, out ulong playerContentId))
return string.Empty;
return _playerNameCache.TryGetValue(playerContentId, out string? playerName) ? playerName : string.Empty;
return _playerCache.TryGetValue(playerContentId, out CachedPlayer? cachedPlayer)
? cachedPlayer.Name
: string.Empty;
}
public IReadOnlyList<string> GetRetainerNamesForCharacter(string characterName, uint world)
@ -73,12 +82,14 @@ internal sealed class PersistenceContext
.AsReadOnly();
}
public void HandleMarketBoardPage(MarketBoardCurrentOfferings listings, ushort worldId)
public void HandleMarketBoardPage(IMarketBoardCurrentOfferings currentOfferings, ushort worldId)
{
try
{
var updates =
listings.ItemListings.DistinctBy(o => o.RetainerId)
currentOfferings.ItemListings
.Cast<MarketBoardCurrentOfferings.MarketBoardItemListing>()
.DistinctBy(o => o.RetainerId)
.Where(l => l.RetainerId != 0)
.Where(l => l.RetainerOwnerId != 0)
.Select(l =>
@ -111,7 +122,8 @@ internal sealed class PersistenceContext
Retainer? dbRetainer = dbContext.Retainers.Find(retainer.LocalContentId);
if (dbRetainer != null)
{
_logger.LogDebug("Updating retainer {RetainerName} with {LocalContentId}", retainer.Name, retainer.LocalContentId);
_logger.LogDebug("Updating retainer {RetainerName} with {LocalContentId}", retainer.Name,
retainer.LocalContentId);
dbRetainer.Name = retainer.Name;
dbRetainer.WorldId = retainer.WorldId;
dbRetainer.OwnerLocalContentId = retainer.OwnerLocalContentId;
@ -119,11 +131,15 @@ internal sealed class PersistenceContext
}
else
{
_logger.LogDebug("Adding retainer {RetainerName} with {LocalContentId}", retainer.Name, retainer.LocalContentId);
_logger.LogDebug("Adding retainer {RetainerName} with {LocalContentId}", retainer.Name,
retainer.LocalContentId);
dbContext.Retainers.Add(retainer);
}
if (!_playerNameCache.TryGetValue(retainer.OwnerLocalContentId, out string? ownerName))
string ownerName;
if (_playerCache.TryGetValue(retainer.OwnerLocalContentId, out CachedPlayer? cachedPlayer))
ownerName = cachedPlayer.Name;
else
ownerName = retainer.OwnerLocalContentId.ToString(CultureInfo.InvariantCulture);
_logger.LogDebug(" Retainer {RetainerName} belongs to {OwnerName}", retainer.Name,
ownerName);
@ -145,45 +161,94 @@ internal sealed class PersistenceContext
}
}
public void HandleContentIdMapping(ContentIdToName mapping)
=> HandleContentIdMapping(new List<ContentIdToName> { mapping });
public void HandleContentIdMapping(IReadOnlyList<ContentIdToName> mappings)
private void HandleContentIdMappingFallback(PlayerMapping mapping)
{
try
{
var updates = mappings.DistinctBy(x => x.ContentId)
.Where(mapping => mapping.ContentId != 0 && !string.IsNullOrEmpty(mapping.PlayerName))
.Where(mapping =>
{
if (_playerNameCache.TryGetValue(mapping.ContentId, out string? existingName))
return mapping.PlayerName != existingName;
if (mapping.ContentId == 0 || string.IsNullOrEmpty(mapping.PlayerName))
return;
return true;
})
.Select(mapping =>
new Player
if (_playerCache.TryGetValue(mapping.ContentId, out CachedPlayer? cachedPlayer))
{
if (mapping.PlayerName == cachedPlayer.Name && mapping.AccountId == cachedPlayer.AccountId)
return;
}
using (var scope = _serviceProvider.CreateScope())
{
using var dbContext = scope.ServiceProvider.GetRequiredService<RetainerTrackContext>();
var dbPlayer = dbContext.Players.Find(mapping.ContentId);
if (dbPlayer == null)
dbContext.Players.Add(new Player
{
LocalContentId = mapping.ContentId,
Name = mapping.PlayerName,
})
.ToList();
AccountId = mapping.AccountId,
});
else
{
dbPlayer.Name = mapping.PlayerName;
dbPlayer.AccountId ??= mapping.AccountId;
dbContext.Entry(dbPlayer).State = EntityState.Modified;
}
if (updates.Count == 0)
return;
int changeCount = dbContext.SaveChanges();
if (changeCount > 0)
{
_logger.LogDebug("Saved fallback player mappings for {ContentId} / {Name} / {AccountId}",
mapping.ContentId, mapping.PlayerName, mapping.AccountId);
}
_playerCache[mapping.ContentId] = new CachedPlayer
{
AccountId = mapping.AccountId,
Name = mapping.PlayerName,
};
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Could not persist singular mapping for {ContentId} / {Name} / {AccountId}",
mapping.ContentId, mapping.PlayerName, mapping.AccountId);
}
}
public void HandleContentIdMapping(IReadOnlyList<PlayerMapping> mappings)
{
var updates = mappings.DistinctBy(x => x.ContentId)
.Where(mapping => mapping.ContentId != 0 && !string.IsNullOrEmpty(mapping.PlayerName))
.Where(mapping =>
{
if (_playerCache.TryGetValue(mapping.ContentId, out CachedPlayer? cachedPlayer))
return mapping.PlayerName != cachedPlayer.Name || mapping.AccountId != cachedPlayer.AccountId;
return true;
})
.ToList();
if (updates.Count == 0)
return;
try
{
using (var scope = _serviceProvider.CreateScope())
{
using var dbContext = scope.ServiceProvider.GetRequiredService<RetainerTrackContext>();
foreach (var update in updates)
{
var dbPlayer = dbContext.Players.Find(update.LocalContentId);
var dbPlayer = dbContext.Players.Find(update.ContentId);
if (dbPlayer == null)
dbContext.Players.Add(update);
dbContext.Players.Add(new Player
{
LocalContentId = update.ContentId,
Name = update.PlayerName,
AccountId = update.AccountId,
});
else
{
dbPlayer.Name = update.Name;
dbContext.Players.Update(dbPlayer);
dbPlayer.Name = update.PlayerName;
dbPlayer.AccountId ??= update.AccountId;
dbContext.Entry(dbPlayer).State = EntityState.Modified;
}
}
@ -192,16 +257,33 @@ internal sealed class PersistenceContext
{
_logger.LogDebug("Saved {Count} player mappings", changeCount);
foreach (var update in updates)
_logger.LogTrace(" {ContentId} = {Name}", update.LocalContentId, update.Name);
_logger.LogTrace(" {ContentId} = {Name} ({AccountId})", update.ContentId, update.PlayerName,
update.AccountId);
}
}
foreach (var player in updates)
_playerNameCache[player.LocalContentId] = player.Name ?? string.Empty;
{
_playerCache[player.ContentId] = new CachedPlayer
{
AccountId = player.AccountId,
Name = player.PlayerName,
};
}
}
catch (Exception e)
{
_logger.LogError(e, "Could not persist multiple mappings");
_logger.LogWarning(e, "Could not persist multiple mappings, attempting non-batch update");
foreach (var update in updates)
{
HandleContentIdMappingFallback(update);
}
}
}
public sealed class CachedPlayer
{
public required ulong? AccountId { get; init; }
public required string Name { get; init; }
}
}

View File

@ -0,0 +1,8 @@
namespace RetainerTrack.Handlers;
internal sealed class PlayerMapping
{
public required ulong? AccountId { get; init; }
public required ulong ContentId { get; init; }
public required string PlayerName { get; init; } = string.Empty;
}

View File

@ -1,72 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Dalamud.NET.Sdk/9.0.2">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<Version>4.0</Version>
<LangVersion>12.0</LangVersion>
<Nullable>enable</Nullable>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SatelliteResourceLanguages>none</SatelliteResourceLanguages>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>dist</OutputPath>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<DebugType>portable</DebugType>
<PathMap Condition="$(SolutionDir) != ''">$(SolutionDir)=X:\</PathMap>
<OutputPath Condition="'$(Configuration)' != 'EF'">dist</OutputPath>
</PropertyGroup>
<PropertyGroup>
<DalamudLibPath>$(appdata)\XIVLauncher\addon\Hooks\dev\</DalamudLibPath>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))'">
<DalamudLibPath>$(DALAMUD_HOME)/</DalamudLibPath>
</PropertyGroup>
<Import Project="..\LLib\LLib.targets"/>
<Import Project="..\LLib\RenameZip.targets"/>
<ItemGroup>
<PackageReference Include="Dalamud.Extensions.MicrosoftLogging" Version="3.0.0" />
<PackageReference Include="DalamudPackager" Version="2.1.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.5" Condition="'$(Configuration)' == 'EF'">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Reference Include="Dalamud">
<HintPath>$(DalamudLibPath)Dalamud.dll</HintPath>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
<Reference Include="ImGui.NET">
<HintPath>$(DalamudLibPath)ImGui.NET.dll</HintPath>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
<Reference Include="ImGuiScene">
<HintPath>$(DalamudLibPath)ImGuiScene.dll</HintPath>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
<Reference Include="Lumina">
<HintPath>$(DalamudLibPath)Lumina.dll</HintPath>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
<Reference Include="Lumina.Excel">
<HintPath>$(DalamudLibPath)Lumina.Excel.dll</HintPath>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(DalamudLibPath)Newtonsoft.Json.dll</HintPath>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
<Reference Include="FFXIVClientStructs">
<HintPath>$(DalamudLibPath)FFXIVClientStructs.dll</HintPath>
<Private Condition="'$(Configuration)' != 'EF'">false</Private>
</Reference>
</ItemGroup>
<Target Name="RenameLatestZip" AfterTargets="PackagePlugin" Condition="'$(Configuration)' == 'Release'">
<Exec Command="rename $(OutDir)$(AssemblyName)\latest.zip $(AssemblyName)-$(Version).zip" />
</Target>
</Project>

View File

@ -10,7 +10,6 @@ using Microsoft.Extensions.Logging;
using RetainerTrack.Commands;
using RetainerTrack.Database;
using RetainerTrack.Database.Compiled;
using RetainerTrack.Database.Migrations;
using RetainerTrack.Handlers;
namespace RetainerTrack;
@ -23,7 +22,7 @@ internal sealed class RetainerTrackPlugin : IDalamudPlugin
private readonly ServiceProvider? _serviceProvider;
public RetainerTrackPlugin(
DalamudPluginInterface pluginInterface,
IDalamudPluginInterface pluginInterface,
IFramework framework,
IClientState clientState,
IGameGui gameGui,
@ -32,6 +31,8 @@ internal sealed class RetainerTrackPlugin : IDalamudPlugin
IAddonLifecycle addonLifecycle,
ICommandManager commandManager,
IDataManager dataManager,
IObjectTable objectTable,
IMarketBoard marketBoard,
IPluginLog pluginLog)
{
ServiceCollection serviceCollection = new();
@ -49,11 +50,13 @@ internal sealed class RetainerTrackPlugin : IDalamudPlugin
serviceCollection.AddSingleton(addonLifecycle);
serviceCollection.AddSingleton(commandManager);
serviceCollection.AddSingleton(dataManager);
serviceCollection.AddSingleton(objectTable);
serviceCollection.AddSingleton(marketBoard);
serviceCollection.AddSingleton<PersistenceContext>();
serviceCollection.AddSingleton<MarketBoardOfferingsHandler>();
serviceCollection.AddSingleton<PartyHandler>();
serviceCollection.AddSingleton<MarketBoardUiHandler>();
serviceCollection.AddSingleton<ObjectTableHandler>();
serviceCollection.AddSingleton<GameHooks>();
serviceCollection.AddSingleton<WhoCommand>();
@ -68,9 +71,9 @@ internal sealed class RetainerTrackPlugin : IDalamudPlugin
RunMigrations(_serviceProvider);
_serviceProvider.GetRequiredService<PartyHandler>();
_serviceProvider.GetRequiredService<MarketBoardOfferingsHandler>();
_serviceProvider.GetRequiredService<MarketBoardUiHandler>();
_serviceProvider.GetRequiredService<ObjectTableHandler>();
_serviceProvider.GetRequiredService<GameHooks>();
_serviceProvider.GetRequiredService<WhoCommand>();
}

View File

@ -13,9 +13,21 @@
},
"DalamudPackager": {
"type": "Direct",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "Sc0PVxvgg4NQjcI8n10/VfUQBAS4O+Fw2pZrAqBdRMbthYGeogzu5+xmIGCGmsEZ/ukMOBuAqiNiB5qA3MRalg=="
"requested": "[2.1.13, )",
"resolved": "2.1.13",
"contentHash": "rMN1omGe8536f4xLMvx9NwfvpAc9YFFfeXJ1t4P4PE6Gu8WCIoFliR1sh07hM+bfODmesk/dvMbji7vNI+B/pQ=="
},
"DotNet.ReproducibleBuilds": {
"type": "Direct",
"requested": "[1.1.1, )",
"resolved": "1.1.1",
"contentHash": "+H2t/t34h6mhEoUvHi8yGXyuZ2GjSovcGYehJrS2MDm2XgmPfZL2Sdxg+uL2lKgZ4M6tTwKHIlxOob2bgh0NRQ==",
"dependencies": {
"Microsoft.SourceLink.AzureRepos.Git": "1.1.1",
"Microsoft.SourceLink.Bitbucket.Git": "1.1.1",
"Microsoft.SourceLink.GitHub": "1.1.1",
"Microsoft.SourceLink.GitLab": "1.1.1"
}
},
"Microsoft.EntityFrameworkCore.Sqlite": {
"type": "Direct",
@ -27,6 +39,21 @@
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.6"
}
},
"Microsoft.SourceLink.Gitea": {
"type": "Direct",
"requested": "[8.0.0, )",
"resolved": "8.0.0",
"contentHash": "KOBodmDnlWGIqZt2hT47Q69TIoGhIApDVLCyyj9TT5ct8ju16AbHYcB4XeknoHX562wO1pMS/1DfBIZK+V+sxg==",
"dependencies": {
"Microsoft.Build.Tasks.Git": "8.0.0",
"Microsoft.SourceLink.Common": "8.0.0"
}
},
"Microsoft.Build.Tasks.Git": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "8.0.5",
@ -157,6 +184,47 @@
"resolved": "8.0.0",
"contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g=="
},
"Microsoft.SourceLink.AzureRepos.Git": {
"type": "Transitive",
"resolved": "1.1.1",
"contentHash": "qB5urvw9LO2bG3eVAkuL+2ughxz2rR7aYgm2iyrB8Rlk9cp2ndvGRCvehk3rNIhRuNtQaeKwctOl1KvWiklv5w==",
"dependencies": {
"Microsoft.Build.Tasks.Git": "1.1.1",
"Microsoft.SourceLink.Common": "1.1.1"
}
},
"Microsoft.SourceLink.Bitbucket.Git": {
"type": "Transitive",
"resolved": "1.1.1",
"contentHash": "cDzxXwlyWpLWaH0em4Idj0H3AmVo3L/6xRXKssYemx+7W52iNskj/SQ4FOmfCb8YQt39otTDNMveCZzYtMoucQ==",
"dependencies": {
"Microsoft.Build.Tasks.Git": "1.1.1",
"Microsoft.SourceLink.Common": "1.1.1"
}
},
"Microsoft.SourceLink.Common": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw=="
},
"Microsoft.SourceLink.GitHub": {
"type": "Transitive",
"resolved": "1.1.1",
"contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==",
"dependencies": {
"Microsoft.Build.Tasks.Git": "1.1.1",
"Microsoft.SourceLink.Common": "1.1.1"
}
},
"Microsoft.SourceLink.GitLab": {
"type": "Transitive",
"resolved": "1.1.1",
"contentHash": "tvsg47DDLqqedlPeYVE2lmiTpND8F0hkrealQ5hYltSmvruy/Gr5nHAKSsjyw5L3NeM/HLMI5ORv7on/M4qyZw==",
"dependencies": {
"Microsoft.Build.Tasks.Git": "1.1.1",
"Microsoft.SourceLink.Common": "1.1.1"
}
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.6",