Compare commits

...

9 Commits
v2.0 ... master

Author SHA1 Message Date
Liza 0d889172bb
Fix load error 2024-07-16 11:17:58 +02:00
Liza 25d25ccf6b
Add /accountid command, use accountid/contentid properties from clientstructs 2024-07-16 10:40:45 +02:00
Liza 59c26c4f30
Performance optimization 2024-07-04 20:51:24 +02:00
Liza 65c0bec80e
API 10 2024-07-03 01:03:00 +02:00
Liza 6bdaca7650
Remove legacy litedb 2024-06-17 00:51:53 +02:00
Liza 90adbdab89
Persistence fixes 2024-06-17 00:34:15 +02:00
Liza 668287a7e2
Fix issue where player names aren't saved 2024-06-10 19:51:56 +02:00
Liza 6763b47509
Migrate from LiteDB to SQLite 2024-05-25 00:12:46 +02:00
Liza 3a72715671
Update icon URL 2024-01-16 06:54:12 +01:00
36 changed files with 2911 additions and 565 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

1017
RetainerTrack/.editorconfig Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,62 @@
using System;
using Dalamud.Game.ClientState.Objects;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Game.Command;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using RetainerTrack.Handlers;
namespace RetainerTrack.Commands
{
internal sealed class AccountIdCommand : IDisposable
{
private readonly ICommandManager _commandManager;
private readonly IClientState _clientState;
private readonly ITargetManager _targetManager;
private readonly IChatGui _chatGui;
private readonly PersistenceContext _persistenceContext;
public AccountIdCommand(ICommandManager commandManager, IClientState clientState, ITargetManager targetManager,
IChatGui chatGui, PersistenceContext persistenceContext)
{
_commandManager = commandManager;
_clientState = clientState;
_targetManager = targetManager;
_chatGui = chatGui;
_persistenceContext = persistenceContext;
_commandManager.AddHandler("/accountid", new CommandInfo(ProcessCommand)
{
HelpMessage = "Shows the accountid of your target (or if no target, yourself)"
});
}
private void ProcessCommand(string command, string arguments)
{
IGameObject? character = _targetManager.Target ?? _clientState.LocalPlayer;
if (character == null || character.ObjectKind != ObjectKind.Player)
return;
unsafe
{
var bc = (BattleChara*)character.Address;
_chatGui.Print($"{character.Name} has Account Id: {bc->AccountId}, Content Id: {bc->ContentId}");
_persistenceContext.HandleContentIdMapping([
new PlayerMapping
{
ContentId = bc->ContentId,
AccountId = bc->AccountId,
PlayerName = bc->NameString,
}
]);
}
}
public void Dispose()
{
_commandManager.RemoveHandler("/accountid");
}
}
}

View File

@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dalamud.Game.Command;
using Dalamud.Plugin.Services;
using Lumina.Excel.GeneratedSheets;
using RetainerTrack.Handlers;
namespace RetainerTrack.Commands;
internal sealed class WhoCommand : IDisposable
{
private readonly PersistenceContext _persistenceContext;
private readonly ICommandManager _commandManager;
private readonly IChatGui _chatGui;
private readonly IClientState _clientState;
private readonly Dictionary<string, uint> _worlds;
public WhoCommand(PersistenceContext persistenceContext, ICommandManager commandManager, IChatGui chatGui,
IClientState clientState, IDataManager dataManager)
{
_persistenceContext = persistenceContext;
_commandManager = commandManager;
_chatGui = chatGui;
_clientState = clientState;
_worlds = dataManager.GetExcelSheet<World>()!.Where(x => x.IsPublic)
.ToDictionary(x => x.Name.ToString().ToUpperInvariant(), x => x.RowId);
_commandManager.AddHandler("/rwho", new CommandInfo(ProcessCommand)
{
HelpMessage =
"/rwho Character Name@World → Shows all retainers for the character (will use your current world if no world is specified)"
});
}
private void ProcessCommand(string command, string arguments)
{
string[] nameParts = arguments.Split(' ');
if (nameParts.Length != 2)
{
_chatGui.Print($"USAGE: /{command} Character Name@World");
}
else if (nameParts[1].Contains('@', StringComparison.Ordinal))
{
string[] lastNameParts = nameParts[1].Split('@');
if (_worlds.TryGetValue(lastNameParts[1].ToUpperInvariant(), out uint worldId))
ProcessLookup($"{nameParts[0]} {lastNameParts[0]}", worldId);
else
_chatGui.PrintError($"Unknown world: {lastNameParts[1]}");
}
else
ProcessLookup(arguments, _clientState?.LocalPlayer?.CurrentWorld?.Id ?? 0);
}
private void ProcessLookup(string name, uint world)
{
if (world == 0)
return;
_chatGui.Print($"Retainer names for {name}: ");
var retainers = _persistenceContext.GetRetainerNamesForCharacter(name, world);
foreach (var retainerName in retainers)
_chatGui.Print($" - {retainerName}");
if (retainers.Count == 0)
_chatGui.Print(" (No retainers found)");
}
public void Dispose()
{
_commandManager.RemoveHandler("/rwho");
}
}

View File

@ -0,0 +1,68 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Sqlite.Storage.Internal;
#pragma warning disable 219, 612, 618
#nullable disable
namespace RetainerTrack.Database.Compiled
{
internal partial class PlayerEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"RetainerTrack.Database.Player",
typeof(Player),
baseEntityType);
var localContentId = runtimeEntityType.AddProperty(
"LocalContentId",
typeof(ulong),
propertyInfo: typeof(Player).GetProperty("LocalContentId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Player).GetField("<LocalContentId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
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),
propertyInfo: typeof(Player).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Player).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
maxLength: 20);
name.TypeMapping = SqliteStringTypeMapping.Default;
var key = runtimeEntityType.AddKey(
new[] { localContentId });
runtimeEntityType.SetPrimaryKey(key);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", null);
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "Players");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@ -0,0 +1,92 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Sqlite.Storage.Internal;
using Microsoft.EntityFrameworkCore.Storage;
#pragma warning disable 219, 612, 618
#nullable disable
namespace RetainerTrack.Database.Compiled
{
internal partial class RetainerEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"RetainerTrack.Database.Retainer",
typeof(Retainer),
baseEntityType);
var localContentId = runtimeEntityType.AddProperty(
"LocalContentId",
typeof(ulong),
propertyInfo: typeof(Retainer).GetProperty("LocalContentId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Retainer).GetField("<LocalContentId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: 0ul);
localContentId.TypeMapping = SqliteULongTypeMapping.Default;
var name = runtimeEntityType.AddProperty(
"Name",
typeof(string),
propertyInfo: typeof(Retainer).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Retainer).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
maxLength: 24);
name.TypeMapping = SqliteStringTypeMapping.Default;
var ownerLocalContentId = runtimeEntityType.AddProperty(
"OwnerLocalContentId",
typeof(ulong),
propertyInfo: typeof(Retainer).GetProperty("OwnerLocalContentId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Retainer).GetField("<OwnerLocalContentId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: 0ul);
ownerLocalContentId.TypeMapping = SqliteULongTypeMapping.Default;
var worldId = runtimeEntityType.AddProperty(
"WorldId",
typeof(ushort),
propertyInfo: typeof(Retainer).GetProperty("WorldId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Retainer).GetField("<WorldId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: (ushort)0);
worldId.TypeMapping = UShortTypeMapping.Default.Clone(
comparer: new ValueComparer<ushort>(
(ushort v1, ushort v2) => v1 == v2,
(ushort v) => (int)v,
(ushort v) => v),
keyComparer: new ValueComparer<ushort>(
(ushort v1, ushort v2) => v1 == v2,
(ushort v) => (int)v,
(ushort v) => v),
providerValueComparer: new ValueComparer<ushort>(
(ushort v1, ushort v2) => v1 == v2,
(ushort v) => (int)v,
(ushort v) => v),
mappingInfo: new RelationalTypeMappingInfo(
storeTypeName: "INTEGER"));
var key = runtimeEntityType.AddKey(
new[] { localContentId });
runtimeEntityType.SetPrimaryKey(key);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", null);
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "Retainers");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@ -0,0 +1,47 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace RetainerTrack.Database.Compiled
{
[DbContext(typeof(RetainerTrackContext))]
public partial class RetainerTrackContextModel : RuntimeModel
{
private static readonly bool _useOldBehavior31751 =
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
static RetainerTrackContextModel()
{
var model = new RetainerTrackContextModel();
if (_useOldBehavior31751)
{
model.Initialize();
}
else
{
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
thread.Start();
thread.Join();
void RunInitialization()
{
model.Initialize();
}
}
model.Customize();
_instance = model;
}
private static RetainerTrackContextModel _instance;
public static IModel Instance => _instance;
partial void Initialize();
partial void Customize();
}
}

View File

@ -0,0 +1,134 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
#pragma warning disable 219, 612, 618
#nullable disable
namespace RetainerTrack.Database.Compiled
{
public partial class RetainerTrackContextModel
{
partial void Initialize()
{
var player = PlayerEntityType.Create(this);
var retainer = RetainerEntityType.Create(this);
PlayerEntityType.CreateAnnotations(player);
RetainerEntityType.CreateAnnotations(retainer);
AddAnnotation("ProductVersion", "8.0.5");
AddRuntimeAnnotation("Relational:RelationalModel", CreateRelationalModel());
}
private IRelationalModel CreateRelationalModel()
{
var relationalModel = new RelationalModel(this);
var player = FindEntityType("RetainerTrack.Database.Player")!;
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);
retainerTrackDatabasePlayerTableBase.Columns.Add("Name", nameColumnBase);
relationalModel.DefaultTables.Add("RetainerTrack.Database.Player", retainerTrackDatabasePlayerTableBase);
var retainerTrackDatabasePlayerMappingBase = new TableMappingBase<ColumnMappingBase>(player, retainerTrackDatabasePlayerTableBase, true);
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>();
player.SetRuntimeAnnotation("Relational:TableMappings", tableMappings);
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 });
playersTable.PrimaryKey = pK_Players;
var pK_PlayersUc = RelationalModel.GetKey(this,
"RetainerTrack.Database.Player",
new[] { "LocalContentId" });
pK_Players.MappedKeys.Add(pK_PlayersUc);
RelationalModel.GetOrCreateUniqueConstraints(pK_PlayersUc).Add(pK_Players);
playersTable.UniqueConstraints.Add("PK_Players", pK_Players);
relationalModel.Tables.Add(("Players", null), playersTable);
var playersTableMapping = new TableMapping(player, playersTable, true);
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")!;
var defaultTableMappings0 = new List<TableMappingBase<ColumnMappingBase>>();
retainer.SetRuntimeAnnotation("Relational:DefaultMappings", defaultTableMappings0);
var retainerTrackDatabaseRetainerTableBase = new TableBase("RetainerTrack.Database.Retainer", null, relationalModel);
var localContentIdColumnBase0 = new ColumnBase<ColumnMappingBase>("LocalContentId", "INTEGER", retainerTrackDatabaseRetainerTableBase);
retainerTrackDatabaseRetainerTableBase.Columns.Add("LocalContentId", localContentIdColumnBase0);
var nameColumnBase0 = new ColumnBase<ColumnMappingBase>("Name", "TEXT", retainerTrackDatabaseRetainerTableBase);
retainerTrackDatabaseRetainerTableBase.Columns.Add("Name", nameColumnBase0);
var ownerLocalContentIdColumnBase = new ColumnBase<ColumnMappingBase>("OwnerLocalContentId", "INTEGER", retainerTrackDatabaseRetainerTableBase);
retainerTrackDatabaseRetainerTableBase.Columns.Add("OwnerLocalContentId", ownerLocalContentIdColumnBase);
var worldIdColumnBase = new ColumnBase<ColumnMappingBase>("WorldId", "INTEGER", retainerTrackDatabaseRetainerTableBase);
retainerTrackDatabaseRetainerTableBase.Columns.Add("WorldId", worldIdColumnBase);
relationalModel.DefaultTables.Add("RetainerTrack.Database.Retainer", retainerTrackDatabaseRetainerTableBase);
var retainerTrackDatabaseRetainerMappingBase = new TableMappingBase<ColumnMappingBase>(retainer, retainerTrackDatabaseRetainerTableBase, true);
retainerTrackDatabaseRetainerTableBase.AddTypeMapping(retainerTrackDatabaseRetainerMappingBase, false);
defaultTableMappings0.Add(retainerTrackDatabaseRetainerMappingBase);
RelationalModel.CreateColumnMapping((ColumnBase<ColumnMappingBase>)localContentIdColumnBase0, retainer.FindProperty("LocalContentId")!, retainerTrackDatabaseRetainerMappingBase);
RelationalModel.CreateColumnMapping((ColumnBase<ColumnMappingBase>)nameColumnBase0, retainer.FindProperty("Name")!, retainerTrackDatabaseRetainerMappingBase);
RelationalModel.CreateColumnMapping((ColumnBase<ColumnMappingBase>)ownerLocalContentIdColumnBase, retainer.FindProperty("OwnerLocalContentId")!, retainerTrackDatabaseRetainerMappingBase);
RelationalModel.CreateColumnMapping((ColumnBase<ColumnMappingBase>)worldIdColumnBase, retainer.FindProperty("WorldId")!, retainerTrackDatabaseRetainerMappingBase);
var tableMappings0 = new List<TableMapping>();
retainer.SetRuntimeAnnotation("Relational:TableMappings", tableMappings0);
var retainersTable = new Table("Retainers", null, relationalModel);
var localContentIdColumn0 = new Column("LocalContentId", "INTEGER", retainersTable);
retainersTable.Columns.Add("LocalContentId", localContentIdColumn0);
var nameColumn0 = new Column("Name", "TEXT", retainersTable);
retainersTable.Columns.Add("Name", nameColumn0);
var ownerLocalContentIdColumn = new Column("OwnerLocalContentId", "INTEGER", retainersTable);
retainersTable.Columns.Add("OwnerLocalContentId", ownerLocalContentIdColumn);
var worldIdColumn = new Column("WorldId", "INTEGER", retainersTable);
retainersTable.Columns.Add("WorldId", worldIdColumn);
var pK_Retainers = new UniqueConstraint("PK_Retainers", retainersTable, new[] { localContentIdColumn0 });
retainersTable.PrimaryKey = pK_Retainers;
var pK_RetainersUc = RelationalModel.GetKey(this,
"RetainerTrack.Database.Retainer",
new[] { "LocalContentId" });
pK_Retainers.MappedKeys.Add(pK_RetainersUc);
RelationalModel.GetOrCreateUniqueConstraints(pK_RetainersUc).Add(pK_Retainers);
retainersTable.UniqueConstraints.Add("PK_Retainers", pK_Retainers);
relationalModel.Tables.Add(("Retainers", null), retainersTable);
var retainersTableMapping = new TableMapping(retainer, retainersTable, true);
retainersTable.AddTypeMapping(retainersTableMapping, false);
tableMappings0.Add(retainersTableMapping);
RelationalModel.CreateColumnMapping(localContentIdColumn0, retainer.FindProperty("LocalContentId")!, retainersTableMapping);
RelationalModel.CreateColumnMapping(nameColumn0, retainer.FindProperty("Name")!, retainersTableMapping);
RelationalModel.CreateColumnMapping(ownerLocalContentIdColumn, retainer.FindProperty("OwnerLocalContentId")!, retainersTableMapping);
RelationalModel.CreateColumnMapping(worldIdColumn, retainer.FindProperty("WorldId")!, retainersTableMapping);
return relationalModel.MakeReadOnly();
}
}
}

View File

@ -0,0 +1,3 @@
[*.cs]
# CA1062: Validate arguments of public methods
dotnet_diagnostic.CA1062.severity = none

View File

@ -0,0 +1,62 @@
// <auto-generated />
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("20240524200204_InitialCreate")]
partial class InitialCreate
{
/// <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<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,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RetainerTrack.Database.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Players",
columns: table => new
{
LocalContentId = table.Column<ulong>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Players", x => x.LocalContentId);
});
migrationBuilder.CreateTable(
name: "Retainers",
columns: table => new
{
LocalContentId = table.Column<ulong>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 24, nullable: false),
WorldId = table.Column<ushort>(type: "INTEGER", nullable: false),
OwnerLocalContentId = table.Column<ulong>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Retainers", x => x.LocalContentId);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Players");
migrationBuilder.DropTable(
name: "Retainers");
}
}
}

View File

@ -0,0 +1,62 @@
// <auto-generated />
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("20240524201345_ImportLegacyData")]
partial class ImportLegacyData
{
/// <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<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,26 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Dalamud.Plugin;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RetainerTrack.Database.Migrations
{
/// <inheritdoc />
public partial class ImportLegacyData : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@ -0,0 +1,62 @@
// <auto-generated />
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("20240524214606_CleanupBrokenPlayerIds")]
partial class CleanupBrokenPlayerIds
{
/// <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<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,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RetainerTrack.Database.Migrations
{
/// <inheritdoc />
public partial class CleanupBrokenPlayerIds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM Players WHERE LocalContentId < 18014398509481984");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

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

@ -0,0 +1,63 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RetainerTrack.Database;
#nullable disable
namespace RetainerTrack.Database.Migrations
{
[DbContext(typeof(RetainerTrackContext))]
partial class RetainerTrackContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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

@ -1,8 +1,14 @@
namespace RetainerTrack.Database
using System.ComponentModel.DataAnnotations;
namespace RetainerTrack.Database;
public class Player
{
internal sealed class Player
{
public ulong Id { get; set; }
public string? Name { get; set; }
}
[Key, Required]
public ulong LocalContentId { get; set; }
[MaxLength(20), Required]
public string? Name { get; set; }
public ulong? AccountId { get; set; }
}

View File

@ -1,10 +1,18 @@
namespace RetainerTrack.Database
using System.ComponentModel.DataAnnotations;
namespace RetainerTrack.Database;
public class Retainer
{
internal sealed class Retainer
{
public ulong Id { get; set; }
public string? Name { get; set; }
public ushort WorldId { get; set; }
public ulong OwnerContentId { get; set; }
}
[Key, Required]
public ulong LocalContentId { get; set; }
[MaxLength(24), Required]
public string? Name { get; set; }
[Required]
public ushort WorldId { get; set; }
[Required]
public ulong OwnerLocalContentId { get; set; }
}

View File

@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore;
namespace RetainerTrack.Database;
internal sealed class RetainerTrackContext : DbContext
{
public DbSet<Retainer> Retainers { get; set; }
public DbSet<Player> Players { get; set; }
public RetainerTrackContext(DbContextOptions<RetainerTrackContext> options)
: base(options)
{
}
}

View File

@ -0,0 +1,19 @@
#if EF
using System;
using System.IO;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace RetainerTrack.Database;
internal sealed class PalClientContextFactory : IDesignTimeDbContextFactory<RetainerTrackContext>
{
public RetainerTrackContext CreateDbContext(string[] args)
{
var optionsBuilder =
new DbContextOptionsBuilder<RetainerTrackContext>().UseSqlite(
$"Data Source={Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "XIVLauncher", "pluginConfigs", "RetainerTrack", RetainerTrackPlugin.DatabaseFileName)}");
return new RetainerTrackContext(optionsBuilder.Options);
}
}
#endif

View File

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

View File

@ -7,63 +7,105 @@ 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;
namespace RetainerTrack.Handlers
namespace RetainerTrack.Handlers;
internal sealed unsafe class GameHooks : IDisposable
{
internal sealed unsafe class GameHooks : IDisposable
{
private readonly ILogger<GameHooks> _logger;
private readonly PersistenceContext _persistenceContext;
private readonly ILogger<GameHooks> _logger;
private readonly PersistenceContext _persistenceContext;
/// <summary>
/// Processes the content id to character name packet, seen e.g. when you hover an item to retrieve the
/// crafter's signature.
/// </summary>
private delegate int CharacterNameResultDelegate(nint a1, ulong contentId, char* playerName);
/// <summary>
/// Processes the content id to character name packet, seen e.g. when you hover an item to retrieve the
/// crafter's signature.
/// </summary>
private delegate int CharacterNameResultDelegate(nint a1, ulong contentId, char* playerName);
private delegate nint SocialListResultDelegate(nint a1, nint dataPtr);
private delegate nint SocialListResultDelegate(nint a1, nint dataPtr);
#pragma warning disable CS0649
[Signature("40 53 48 83 EC 20 48 8B D9 33 C9 45 33 C9", DetourName = nameof(ProcessCharacterNameResult))]
private Hook<CharacterNameResultDelegate> CharacterNameResultHook { get; init; } = null!;
[Signature("40 53 48 83 EC 20 48 8B D9 33 C9 45 33 C9", DetourName = nameof(ProcessCharacterNameResult))]
private Hook<CharacterNameResultDelegate> CharacterNameResultHook { get; init; } = null!;
// Signature adapted from https://github.com/LittleNightmare/UsedName
[Signature("48 89 5C 24 10 56 48 83 EC 20 48 ?? ?? ?? ?? ?? ?? 48 8B F2 E8 ?? ?? ?? ?? 48 8B D8",
DetourName = nameof(ProcessSocialListResult))]
private Hook<SocialListResultDelegate> SocialListResultHook { get; init; } = null!;
// Signature adapted from https://github.com/LittleNightmare/UsedName
[Signature("48 89 5C 24 10 56 48 83 EC 20 48 ?? ?? ?? ?? ?? ?? 48 8B F2 E8 ?? ?? ?? ?? 48 8B D8",
DetourName = nameof(ProcessSocialListResult))]
private Hook<SocialListResultDelegate> SocialListResultHook { get; init; } = null!;
#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;
_logger.LogDebug("Initializing game hooks");
gameInteropProvider.InitializeFromAttributes(this);
CharacterNameResultHook.Enable();
SocialListResultHook.Enable();
_logger.LogDebug("Game hooks initialized");
}
private int ProcessCharacterNameResult(nint a1, ulong contentId, char* playerName)
{
try
{
_logger = logger;
_persistenceContext = persistenceContext;
var mapping = new PlayerMapping
{
ContentId = contentId,
AccountId = null,
PlayerName = MemoryHelper.ReadString(new nint(playerName), Encoding.ASCII, 32),
};
_logger.LogDebug("Initializing game hooks");
gameInteropProvider.InitializeFromAttributes(this);
CharacterNameResultHook.Enable();
SocialListResultHook.Enable();
_logger.LogDebug("Game hooks initialized");
if (!string.IsNullOrEmpty(mapping.PlayerName))
{
_logger.LogTrace("Content id {ContentId} belongs to '{Name}'", mapping.ContentId,
mapping.PlayerName);
if (mapping.PlayerName.IsValidCharacterName())
Task.Run(() => _persistenceContext.HandleContentIdMapping(new List<PlayerMapping> { mapping }));
}
else
{
_logger.LogDebug("Content id {ContentId} didn't resolve to a player name, ignoring",
mapping.ContentId);
}
}
catch (Exception e)
{
_logger.LogError(e, "Could not process character name result");
}
private int ProcessCharacterNameResult(nint a1, ulong contentId, char* playerName)
return CharacterNameResultHook.Original(a1, contentId, playerName);
}
private nint ProcessSocialListResult(nint a1, nint dataPtr)
{
try
{
try
var result = Marshal.PtrToStructure<SocialListResultPage>(dataPtr);
List<PlayerMapping> mappings = new();
foreach (SocialListPlayer player in result.PlayerSpan)
{
var mapping = new ContentIdToName
if (player.ContentId == 0)
continue;
var mapping = new PlayerMapping
{
ContentId = contentId,
PlayerName = MemoryHelper.ReadString(new nint(playerName), Encoding.ASCII, 32),
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.LogTrace("Content id {ContentId} belongs to '{Name}'", mapping.ContentId,
mapping.PlayerName);
Task.Run(() => _persistenceContext.HandleContentIdMapping(mapping));
_logger.LogDebug("Content id {ContentId} belongs to '{Name}' ({AccountId})", mapping.ContentId,
mapping.PlayerName, mapping.AccountId);
mappings.Add(mapping);
}
else
{
@ -71,92 +113,62 @@ namespace RetainerTrack.Handlers
mapping.ContentId);
}
}
catch (Exception e)
{
_logger.LogError(e, "Could not process character name result");
}
return CharacterNameResultHook.Original(a1, contentId, playerName);
if (mappings.Count > 0)
Task.Run(() => _persistenceContext.HandleContentIdMapping(mappings));
}
private nint ProcessSocialListResult(nint a1, nint dataPtr)
catch (Exception e)
{
try
{
var result = Marshal.PtrToStructure<SocialListResultPage>(dataPtr);
List<ContentIdToName> mappings = new();
foreach (SocialListPlayer player in result.PlayerSpan)
{
var mapping = new ContentIdToName
{
ContentId = player.ContentId,
PlayerName = MemoryHelper.ReadString(new nint(player.CharacterName), Encoding.ASCII, 32),
};
if (!string.IsNullOrEmpty(mapping.PlayerName))
{
_logger.LogTrace("Content id {ContentId} belongs to '{Name}'", mapping.ContentId,
mapping.PlayerName);
mappings.Add(mapping);
}
else
{
_logger.LogDebug("Content id {ContentId} didn't resolve to a player name, ignoring",
mapping.ContentId);
}
}
if (mappings.Count > 0)
Task.Run(() => _persistenceContext.HandleContentIdMapping(mappings));
}
catch (Exception e)
{
_logger.LogError(e, "Could not process social list result");
}
return SocialListResultHook.Original(a1, dataPtr);
_logger.LogError(e, "Could not process social list result");
}
public void Dispose()
{
CharacterNameResultHook.Dispose();
SocialListResultHook.Dispose();
}
return SocialListResultHook.Original(a1, dataPtr);
}
public void Dispose()
{
CharacterNameResultHook.Dispose();
SocialListResultHook.Dispose();
}
/// <summary>
/// There are some caveats here, the social list includes a LOT of things with different types
/// (we don't care for the result type in this plugin), see sapphire for which field is the type.
///
/// 1 = party
/// 2 = friend list
/// 3 = link shell
/// 4 = player search
/// 5 = fc short list (first tab, with company board + actions + online members)
/// 6 = fc long list (members tab)
///
/// Both 1 and 2 are sent to you on login, unprompted.
/// </summary>
[StructLayout(LayoutKind.Explicit, Size = 0x420)]
internal struct SocialListResultPage
{
[FieldOffset(0x10)] private fixed byte Players[10 * 0x70];
public Span<SocialListPlayer> PlayerSpan => new(Unsafe.AsPointer(ref Players[0]), 10);
}
[StructLayout(LayoutKind.Explicit, Size = 0x70, Pack = 1)]
internal struct SocialListPlayer
{
/// <summary>
/// If this is set, it means there is a player present in this slot (even if no name can be retrieved),
/// 0 if empty.
/// </summary>
[FieldOffset(0x00)] public readonly ulong ContentId;
/// <summary>
/// There are some caveats here, the social list includes a LOT of things with different types
/// (we don't care for the result type in this plugin), see sapphire for which field is the type.
///
/// 1 = party
/// 2 = friend list
/// 3 = link shell
/// 4 = player search
/// 5 = fc short list (first tab, with company board + actions + online members)
/// 6 = fc long list (members tab)
///
/// Both 1 and 2 are sent to you on login, unprompted.
/// Only seems to be set for certain kind of social lists, e.g. friend list/FC members doesn't include any.
/// </summary>
[StructLayout(LayoutKind.Explicit, Size = 0x380)]
internal struct SocialListResultPage
{
[FieldOffset(0x10)] private fixed byte Players[10 * 0x58];
[FieldOffset(0x18)] public readonly ulong AccountId;
public Span<SocialListPlayer> PlayerSpan => new(Unsafe.AsPointer(ref Players[0]), 10);
}
[StructLayout(LayoutKind.Explicit, Size = 0x58)]
internal struct SocialListPlayer
{
/// <summary>
/// If this is set, it means there is a player present in this slot (even if no name can be retrieved),
/// 0 if empty.
/// </summary>
[FieldOffset(0x00)] public readonly ulong ContentId;
/// <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(0x31)] public fixed byte CharacterName[32];
}
/// <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(0x44)] public fixed byte CharacterName[32];
}
}

View File

@ -1,79 +1,46 @@
using System;
using System.Threading.Tasks;
using Dalamud.Game.Network.Structures;
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Info;
using Microsoft.Extensions.Logging;
namespace RetainerTrack.Handlers
namespace RetainerTrack.Handlers;
internal sealed class MarketBoardOfferingsHandler : IDisposable
{
internal sealed class MarketBoardOfferingsHandler : IDisposable
private readonly IMarketBoard _marketBoard;
private readonly ILogger<MarketBoardOfferingsHandler> _logger;
private readonly IClientState _clientState;
private readonly PersistenceContext _persistenceContext;
public MarketBoardOfferingsHandler(
IMarketBoard marketBoard,
ILogger<MarketBoardOfferingsHandler> logger,
IClientState clientState,
PersistenceContext persistenceContext)
{
private unsafe delegate void* MarketBoardOfferings(InfoProxyItemSearch* a1, nint packetData);
_marketBoard = marketBoard;
_logger = logger;
_clientState = clientState;
_persistenceContext = persistenceContext;
private readonly ILogger<MarketBoardOfferingsHandler> _logger;
private readonly IClientState _clientState;
private readonly PersistenceContext _persistenceContext;
private readonly Hook<MarketBoardOfferings> _marketBoardOfferingsHook;
_marketBoard.OfferingsReceived += HandleOfferings;
}
public unsafe MarketBoardOfferingsHandler(
ILogger<MarketBoardOfferingsHandler> logger,
IClientState clientState,
IGameGui gameGui,
IGameInteropProvider gameInteropProvider,
PersistenceContext persistenceContext)
public void Dispose()
{
_marketBoard.OfferingsReceived += HandleOfferings;
}
private void HandleOfferings(IMarketBoardCurrentOfferings currentOfferings)
{
ushort worldId = (ushort?)_clientState.LocalPlayer?.CurrentWorld.Id ?? 0;
if (worldId == 0)
{
_logger = logger;
_clientState = clientState;
_persistenceContext = persistenceContext;
_logger.LogDebug("Setting up offerings hook");
var uiModule = (UIModule*)gameGui.GetUIModule();
var infoModule = uiModule->GetInfoModule();
var proxy = infoModule->GetInfoProxyById(11);
_marketBoardOfferingsHook =
gameInteropProvider.HookFromAddress<MarketBoardOfferings>((nint)proxy->vtbl[12],
MarketBoardOfferingsDetour);
_marketBoardOfferingsHook.Enable();
_logger.LogDebug("Offerings hook enabled successfully");
_logger.LogInformation("Skipping market board handler, current world unknown");
return;
}
public void Dispose()
{
_marketBoardOfferingsHook.Dispose();
}
// 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)
{
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));
}
Task.Run(() => _persistenceContext.HandleMarketBoardPage(currentOfferings, worldId));
}
}

View File

@ -1,80 +1,84 @@
using System;
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using Microsoft.Extensions.Logging;
namespace RetainerTrack.Handlers
namespace RetainerTrack.Handlers;
internal sealed unsafe class MarketBoardUiHandler : IDisposable
{
internal sealed unsafe class MarketBoardUiHandler : IDisposable
private const string AddonName = "ItemSearchResult";
private readonly ILogger<MarketBoardUiHandler> _logger;
private readonly PersistenceContext _persistenceContext;
private readonly IAddonLifecycle _addonLifecycle;
public MarketBoardUiHandler(
ILogger<MarketBoardUiHandler> logger,
PersistenceContext persistenceContext,
IAddonLifecycle addonLifecycle)
{
private const string AddonName = "ItemSearchResult";
_logger = logger;
_persistenceContext = persistenceContext;
_addonLifecycle = addonLifecycle;
private readonly ILogger<MarketBoardUiHandler> _logger;
private readonly PersistenceContext _persistenceContext;
private readonly IAddonLifecycle _addonLifecycle;
_addonLifecycle.RegisterListener(AddonEvent.PreDraw, AddonName, PreDraw);
}
public MarketBoardUiHandler(
ILogger<MarketBoardUiHandler> logger,
PersistenceContext persistenceContext,
IAddonLifecycle addonLifecycle)
private void PreDraw(AddonEvent type, AddonArgs args)
{
UpdateRetainerNames((AddonItemSearchResult*)args.Addon);
}
private void UpdateRetainerNames(AddonItemSearchResult* addon)
{
try
{
_logger = logger;
_persistenceContext = persistenceContext;
_addonLifecycle = addonLifecycle;
if (addon == null || !addon->AtkUnitBase.IsVisible)
return;
_addonLifecycle.RegisterListener(AddonEvent.PreDraw, AddonName, PreDraw);
}
var results = addon->Results;
if (results == null)
return;
private void PreDraw(AddonEvent type, AddonArgs args)
{
UpdateRetainerNames((AddonItemSearchResult*)args.Addon);
}
int length = results->ListLength;
if (length == 0)
return;
private void UpdateRetainerNames(AddonItemSearchResult* addon)
{
try
for (int i = 0; i < length; ++i)
{
if (addon == null || !addon->AtkUnitBase.IsVisible)
var listItem = results->ItemRendererList[i].AtkComponentListItemRenderer;
if (listItem == null)
return;
var results = addon->Results;
if (results == null)
var uldManager = listItem->AtkComponentButton.AtkComponentBase.UldManager;
if (uldManager.NodeListCount < 14)
continue;
var retainerNameNode = (AtkTextNode*)uldManager.NodeList[5];
if (retainerNameNode == null)
return;
int length = results->ListLength;
if (length == 0)
return;
for (int i = 0; i < length; ++i)
string retainerName = retainerNameNode->NodeText.ToString();
if (!retainerName.Contains('(', StringComparison.Ordinal))
{
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})");
}
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()
catch (Exception e)
{
_addonLifecycle.UnregisterListener(AddonEvent.PreDraw, AddonName, PreDraw);
_logger.LogInformation(e, "Market board draw failed");
}
}
public void Dispose()
{
_addonLifecycle.UnregisterListener(AddonEvent.PreDraw, AddonName, PreDraw);
}
}

View File

@ -0,0 +1,71 @@
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;
if (bc->ContentId == 0 || bc->AccountId == 0)
continue;
playerMappings.Add(new PlayerMapping
{
ContentId = bc->ContentId,
AccountId = bc->AccountId,
PlayerName = bc->NameString,
});
}
}
if (playerMappings.Count > 0)
Task.Run(() => _persistenceContext.HandleContentIdMapping(playerMappings));
_logger.LogTrace("ObjectTable handling for {Count} players took {TimeMs}", playerMappings.Count, TimeSpan.FromMilliseconds(Environment.TickCount64 - now));
}
public void Dispose()
{
_framework.Update -= FrameworkUpdate;
}
}

View File

@ -1,69 +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 = 0;
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 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

@ -1,128 +1,289 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Dalamud.Game.Network.Structures;
using Dalamud.Plugin.Services;
using LiteDB;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using RetainerTrack.Database;
namespace RetainerTrack.Handlers
namespace RetainerTrack.Handlers;
internal sealed class PersistenceContext
{
internal sealed class PersistenceContext
private readonly ILogger<PersistenceContext> _logger;
private readonly IClientState _clientState;
private readonly IServiceProvider _serviceProvider;
private readonly ConcurrentDictionary<uint, ConcurrentDictionary<string, ulong>> _worldRetainerCache = new();
private readonly ConcurrentDictionary<ulong, CachedPlayer> _playerCache = new();
public PersistenceContext(ILogger<PersistenceContext> logger, IClientState clientState,
IServiceProvider serviceProvider)
{
private readonly ILogger<PersistenceContext> _logger;
private readonly IClientState _clientState;
private readonly LiteDatabase _liteDatabase;
private readonly ConcurrentDictionary<uint, ConcurrentDictionary<string, ulong>> _worldRetainerCache = new();
private readonly ConcurrentDictionary<ulong, string> _playerNameCache = new();
_logger = logger;
_clientState = clientState;
_serviceProvider = serviceProvider;
public PersistenceContext(ILogger<PersistenceContext> logger, IClientState clientState,
LiteDatabase liteDatabase)
using (IServiceScope scope = serviceProvider.CreateScope())
{
_logger = logger;
_clientState = clientState;
_liteDatabase = liteDatabase;
using var dbContext = scope.ServiceProvider.GetRequiredService<RetainerTrackContext>();
var retainersByWorld = dbContext.Retainers.GroupBy(retainer => retainer.WorldId);
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;
world[retainer.Name] = retainer.OwnerLocalContentId;
}
}
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
foreach (var player in dbContext.Players)
{
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)
_playerCache[player.LocalContentId] = new CachedPlayer
{
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))
.Where(mapping =>
{
if (_playerNameCache.TryGetValue(mapping.ContentId, out string? existingName))
return mapping.PlayerName != existingName;
return true;
})
.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");
AccountId = player.AccountId,
Name = 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 _playerCache.TryGetValue(playerContentId, out CachedPlayer? cachedPlayer)
? cachedPlayer.Name
: string.Empty;
}
public IReadOnlyList<string> GetRetainerNamesForCharacter(string characterName, uint world)
{
using var scope = _serviceProvider.CreateScope();
using var dbContext = scope.ServiceProvider.GetRequiredService<RetainerTrackContext>();
return dbContext.Players.Where(p => characterName == p.Name)
.SelectMany(player =>
dbContext.Retainers.Where(x => x.OwnerLocalContentId == player.LocalContentId && x.WorldId == world))
.Select(x => x.Name)
.Where(x => !string.IsNullOrEmpty(x))
.Cast<string>()
.ToList()
.AsReadOnly();
}
public void HandleMarketBoardPage(IMarketBoardCurrentOfferings currentOfferings, ushort worldId)
{
try
{
var updates =
currentOfferings.ItemListings
.Cast<MarketBoardCurrentOfferings.MarketBoardItemListing>()
.DistinctBy(o => o.RetainerId)
.Where(l => l.RetainerId != 0)
.Where(l => l.RetainerOwnerId != 0)
.Select(l =>
new Retainer
{
LocalContentId = l.RetainerId,
Name = l.RetainerName,
WorldId = worldId,
OwnerLocalContentId = l.RetainerOwnerId,
})
.Where(mapping =>
{
if (mapping.Name == null)
return true;
var currentWorldCache = _worldRetainerCache.GetOrAdd(mapping.WorldId, _ => new());
if (currentWorldCache.TryGetValue(mapping.Name, out ulong playerContentId))
return mapping.OwnerLocalContentId != playerContentId;
return true;
})
.DistinctBy(x => x.LocalContentId)
.ToList();
using var scope = _serviceProvider.CreateScope();
using var dbContext = scope.ServiceProvider.GetRequiredService<RetainerTrackContext>();
foreach (var retainer in updates)
{
Retainer? dbRetainer = dbContext.Retainers.Find(retainer.LocalContentId);
if (dbRetainer != null)
{
_logger.LogDebug("Updating retainer {RetainerName} with {LocalContentId}", retainer.Name,
retainer.LocalContentId);
dbRetainer.Name = retainer.Name;
dbRetainer.WorldId = retainer.WorldId;
dbRetainer.OwnerLocalContentId = retainer.OwnerLocalContentId;
dbContext.Retainers.Update(dbRetainer);
}
else
{
_logger.LogDebug("Adding retainer {RetainerName} with {LocalContentId}", retainer.Name,
retainer.LocalContentId);
dbContext.Retainers.Add(retainer);
}
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);
if (retainer.Name != null)
{
var world = _worldRetainerCache.GetOrAdd(retainer.WorldId, _ => new());
world[retainer.Name] = retainer.OwnerLocalContentId;
}
}
int changeCount = dbContext.SaveChanges();
if (changeCount > 0)
_logger.LogDebug("Saved {Count} retainer mappings", changeCount);
}
catch (Exception e)
{
_logger.LogError(e, "Could not persist retainer info from market board page");
}
}
private void HandleContentIdMappingFallback(PlayerMapping mapping)
{
try
{
if (mapping.ContentId == 0 || string.IsNullOrEmpty(mapping.PlayerName))
return;
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,
AccountId = mapping.AccountId,
});
else
{
dbPlayer.Name = mapping.PlayerName;
dbPlayer.AccountId ??= mapping.AccountId;
dbContext.Entry(dbPlayer).State = EntityState.Modified;
}
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.ContentId);
if (dbPlayer == null)
dbContext.Players.Add(new Player
{
LocalContentId = update.ContentId,
Name = update.PlayerName,
AccountId = update.AccountId,
});
else
{
dbPlayer.Name = update.PlayerName;
dbPlayer.AccountId ??= update.AccountId;
dbContext.Entry(dbPlayer).State = EntityState.Modified;
}
}
int changeCount = dbContext.SaveChanges();
if (changeCount > 0)
{
_logger.LogDebug("Saved {Count} player mappings", changeCount);
foreach (var update in updates)
_logger.LogTrace(" {ContentId} = {Name} ({AccountId})", update.ContentId, update.PlayerName,
update.AccountId);
}
}
foreach (var player in updates)
{
_playerCache[player.ContentId] = new CachedPlayer
{
AccountId = player.AccountId,
Name = player.PlayerName,
};
}
}
catch (Exception e)
{
_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,68 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Dalamud.NET.Sdk/9.0.2">
<PropertyGroup>
<TargetFramework>net7.0-windows</TargetFramework>
<Version>2.0</Version>
<LangVersion>11.0</LangVersion>
<Nullable>enable</Nullable>
<Version>4.3</Version>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<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>
<SatelliteResourceLanguages>none</SatelliteResourceLanguages>
<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="2.0.0" />
<PackageReference Include="DalamudPackager" Version="2.1.12" />
<PackageReference Include="LiteDB" Version="5.0.17" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Dalamud.Extensions.MicrosoftLogging" Version="3.0.0" />
<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>false</Private>
</Reference>
<Reference Include="ImGui.NET">
<HintPath>$(DalamudLibPath)ImGui.NET.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ImGuiScene">
<HintPath>$(DalamudLibPath)ImGuiScene.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina">
<HintPath>$(DalamudLibPath)Lumina.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina.Excel">
<HintPath>$(DalamudLibPath)Lumina.Excel.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(DalamudLibPath)Newtonsoft.Json.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="FFXIVClientStructs">
<HintPath>$(DalamudLibPath)FFXIVClientStructs.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<Target Name="RenameLatestZip" AfterTargets="PackagePlugin">
<Exec Command="rename $(OutDir)$(AssemblyName)\latest.zip $(AssemblyName)-$(Version).zip" />
</Target>
</Project>

View File

@ -4,6 +4,6 @@
"Punchline": "Track who a retainer belongs to.",
"Description": "Keeps track of who a retainer belongs to - if you have seen the retainer's owner locally (if you were on the same party, or if you have seen an item crafted by them).\n\nTracking is unavailable if the crafter isn't the retainer owner (to avoid resellers being attributed incorrectly), and if the tooltip says 'Obtaining Signature'.",
"RepoUrl": "https://git.carvel.li/liza/RetainerTrack",
"IconUrl": "https://git.carvel.li/liza/plugin-repo/raw/branch/master/dist/RetainerTrack.png",
"IconUrl": "https://plugins.carvel.li/icons/RetainerTrack.png",
"Tags": ["retainer", "track"]
}

View File

@ -1,72 +1,109 @@
using System.IO;
using System;
using System.IO;
using Dalamud.Extensions.MicrosoftLogging;
using Dalamud.Game.ClientState.Objects;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using LiteDB;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using RetainerTrack.Commands;
using RetainerTrack.Database;
using RetainerTrack.Database.Compiled;
using RetainerTrack.Handlers;
namespace RetainerTrack
namespace RetainerTrack;
// ReSharper disable once UnusedType.Global
internal sealed class RetainerTrackPlugin : IDalamudPlugin
{
// ReSharper disable once UnusedType.Global
internal sealed class RetainerTrackPlugin : IDalamudPlugin
public const string DatabaseFileName = "retainertrack.data.sqlite3";
private readonly string _sqliteConnectionString;
private readonly ServiceProvider? _serviceProvider;
public RetainerTrackPlugin(
IDalamudPluginInterface pluginInterface,
IFramework framework,
IClientState clientState,
IGameGui gameGui,
IChatGui chatGui,
IGameInteropProvider gameInteropProvider,
IAddonLifecycle addonLifecycle,
ICommandManager commandManager,
IDataManager dataManager,
ITargetManager targetManager,
IObjectTable objectTable,
IMarketBoard marketBoard,
IPluginLog pluginLog)
{
private readonly ServiceProvider? _serviceProvider;
ServiceCollection serviceCollection = new();
serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Trace)
.ClearProviders()
.AddDalamudLogger(pluginLog)
.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning));
serviceCollection.AddSingleton<IDalamudPlugin>(this);
serviceCollection.AddSingleton(pluginInterface);
serviceCollection.AddSingleton(framework);
serviceCollection.AddSingleton(clientState);
serviceCollection.AddSingleton(gameGui);
serviceCollection.AddSingleton(chatGui);
serviceCollection.AddSingleton(gameInteropProvider);
serviceCollection.AddSingleton(addonLifecycle);
serviceCollection.AddSingleton(commandManager);
serviceCollection.AddSingleton(dataManager);
serviceCollection.AddSingleton(targetManager);
serviceCollection.AddSingleton(objectTable);
serviceCollection.AddSingleton(marketBoard);
public RetainerTrackPlugin(
DalamudPluginInterface pluginInterface,
IFramework framework,
IClientState clientState,
IGameGui gameGui,
IGameInteropProvider gameInteropProvider,
IAddonLifecycle addonLifecycle,
IPluginLog pluginLog)
{
ServiceCollection serviceCollection = new();
serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Trace)
.ClearProviders()
.AddDalamudLogger(pluginLog));
serviceCollection.AddSingleton<IDalamudPlugin>(this);
serviceCollection.AddSingleton(pluginInterface);
serviceCollection.AddSingleton(framework);
serviceCollection.AddSingleton(clientState);
serviceCollection.AddSingleton(gameGui);
serviceCollection.AddSingleton(gameInteropProvider);
serviceCollection.AddSingleton(addonLifecycle);
serviceCollection.AddSingleton<PersistenceContext>();
serviceCollection.AddSingleton<MarketBoardOfferingsHandler>();
serviceCollection.AddSingleton<MarketBoardUiHandler>();
serviceCollection.AddSingleton<ObjectTableHandler>();
serviceCollection.AddSingleton<GameHooks>();
serviceCollection.AddSingleton<AccountIdCommand>();
serviceCollection.AddSingleton<WhoCommand>();
serviceCollection.AddSingleton<LiteDatabase>(_ =>
new LiteDatabase(new ConnectionString
{
Filename = Path.Join(pluginInterface.GetPluginConfigDirectory(), "retainer-data.litedb"),
Connection = ConnectionType.Direct,
Upgrade = true,
}));
_sqliteConnectionString = PrepareSqliteDb(serviceCollection, pluginInterface.GetPluginConfigDirectory());
_serviceProvider = serviceCollection.BuildServiceProvider();
serviceCollection.AddSingleton<PersistenceContext>();
serviceCollection.AddSingleton<MarketBoardOfferingsHandler>();
serviceCollection.AddSingleton<PartyHandler>();
serviceCollection.AddSingleton<MarketBoardUiHandler>();
serviceCollection.AddSingleton<GameHooks>();
_serviceProvider = serviceCollection.BuildServiceProvider();
RunMigrations(_serviceProvider);
InitializeRequiredServices(_serviceProvider);
}
LiteDatabase liteDatabase = _serviceProvider.GetRequiredService<LiteDatabase>();
liteDatabase.GetCollection<Retainer>()
.EnsureIndex(x => x.Id);
liteDatabase.GetCollection<Player>()
.EnsureIndex(x => x.Id);
private static string PrepareSqliteDb(IServiceCollection serviceCollection, string getPluginConfigDirectory)
{
string connectionString = $"Data Source={Path.Join(getPluginConfigDirectory, DatabaseFileName)}";
serviceCollection.AddDbContext<RetainerTrackContext>(o => o
.UseSqlite(connectionString)
.UseModel(RetainerTrackContextModel.Instance));
return connectionString;
}
_serviceProvider.GetRequiredService<PartyHandler>();
_serviceProvider.GetRequiredService<MarketBoardOfferingsHandler>();
_serviceProvider.GetRequiredService<MarketBoardUiHandler>();
_serviceProvider.GetRequiredService<GameHooks>();
}
private static void RunMigrations(IServiceProvider serviceProvider)
{
using var scope = serviceProvider.CreateScope();
using var dbContext = scope.ServiceProvider.GetRequiredService<RetainerTrackContext>();
dbContext.Database.Migrate();
}
public void Dispose()
{
_serviceProvider?.Dispose();
}
private static void InitializeRequiredServices(ServiceProvider serviceProvider)
{
serviceProvider.GetRequiredService<MarketBoardOfferingsHandler>();
serviceProvider.GetRequiredService<MarketBoardUiHandler>();
serviceProvider.GetRequiredService<ObjectTableHandler>();
serviceProvider.GetRequiredService<GameHooks>();
serviceProvider.GetRequiredService<AccountIdCommand>();
serviceProvider.GetRequiredService<WhoCommand>();
}
public void Dispose()
{
_serviceProvider?.Dispose();
// ensure we're not keeping the file open longer than the plugin is loaded
using (SqliteConnection sqliteConnection = new(_sqliteConnectionString))
SqliteConnection.ClearPool(sqliteConnection);
}
}

View File

@ -1,73 +1,290 @@
{
"version": 1,
"dependencies": {
"net7.0-windows7.0": {
"net8.0-windows7.0": {
"Dalamud.Extensions.MicrosoftLogging": {
"type": "Direct",
"requested": "[2.0.0, )",
"resolved": "2.0.0",
"contentHash": "qp2idn5GuPouUxHHFytMrorbhlcupsgPdO87HjxlBfTY+JID+qoTfPmA5V6HBP1a4DuXGPbk4JtoO/hMmnQrtw==",
"requested": "[3.0.0, )",
"resolved": "3.0.0",
"contentHash": "jWK3r/cZUXN8H9vHf78gEzeRmMk4YAbCUYzLcTqUAcega8unUiFGwYy+iOjVYJ9urnr9r+hk+vBi1y9wyv+e7Q==",
"dependencies": {
"Microsoft.Extensions.Logging": "7.0.0"
"Microsoft.Extensions.Logging": "8.0.0"
}
},
"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=="
},
"LiteDB": {
"DotNet.ReproducibleBuilds": {
"type": "Direct",
"requested": "[5.0.17, )",
"resolved": "5.0.17",
"contentHash": "cKPvkdlzIts3ZKu/BzoIc/Y71e4VFKlij4LyioPFATZMot+wB7EAm1FFbZSJez6coJmQUoIg/3yHE1MMU+zOdg=="
"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",
"requested": "[8.0.5, )",
"resolved": "8.0.5",
"contentHash": "rBTx2TP+pa+CgXIxWmUbPdO+53WV4Nmq9Njb5Olomh4og/p5qV1jU53wPpqO92gEv+ZR6arwP5Pe11XImYTT+A==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Sqlite.Core": "8.0.5",
"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",
"contentHash": "JMGBNGTPsrLM14j5gDG2r5/I1nbbQd1ZdgeUnF7uca8RHYin6wZpFtQNYYqOMUpSxJak55trXE9B8/X2X+pOXw==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.6"
}
},
"Microsoft.EntityFrameworkCore": {
"type": "Transitive",
"resolved": "8.0.5",
"contentHash": "sqpDZgfzmTPXy/jCekqTaPDwqRDjtdGmIL+eqFfXtVAoH4AanWjeyxQ1ej3uVnTQO6f23+m9+ggJDVcgyPJxcA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Abstractions": "8.0.5",
"Microsoft.EntityFrameworkCore.Analyzers": "8.0.5",
"Microsoft.Extensions.Caching.Memory": "8.0.0",
"Microsoft.Extensions.Logging": "8.0.0"
}
},
"Microsoft.EntityFrameworkCore.Abstractions": {
"type": "Transitive",
"resolved": "8.0.5",
"contentHash": "qwYdfjFKtmTXX8NIm0MuZxUkon1tcw+aF5huzR7YOVr/tR3s4fqw9DWcvc23l3Jhpo/uGHWZcNPyFlI2CD3Usg=="
},
"Microsoft.EntityFrameworkCore.Analyzers": {
"type": "Transitive",
"resolved": "8.0.5",
"contentHash": "LzoKedC+9A8inF5d3iIzgyv/JDXgKrtpYoGIC3EqGWuHVDm9s/IHHApeTOTbzvnr7yBVV+nmYfyT1nwtzRDp0Q=="
},
"Microsoft.EntityFrameworkCore.Relational": {
"type": "Transitive",
"resolved": "8.0.5",
"contentHash": "x2bdSK3eKKEQkDdYcGxxDU+S7NqhBiz/Fciz01Mafz9P71VRdP3JskKHaZvwK0/sNEAT3hS7BTsDQGUA2F9mAA==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "8.0.5",
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0"
}
},
"Microsoft.EntityFrameworkCore.Sqlite.Core": {
"type": "Transitive",
"resolved": "8.0.5",
"contentHash": "txwDTpgWFeuTLHh4gYxzKnSWx2jtpX3qxRYkMgfLmjZAe5vYxHKPsTNCa7AKR78ZqrUM7iZ5bBiS3s1Q7oZi4g==",
"dependencies": {
"Microsoft.Data.Sqlite.Core": "8.0.5",
"Microsoft.EntityFrameworkCore.Relational": "8.0.5",
"Microsoft.Extensions.DependencyModel": "8.0.0"
}
},
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Caching.Memory": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "7pqivmrZDzo1ADPkRwjy+8jtRKWRCPag9qPI+p7sgu7Q4QreWhcvbiWXsbhP+yY8XSiDvZpu2/LWdBv7PnmOpQ==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "8.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "elNeOmkeX3eDVG6pYVeV82p29hr+UKDaBhrZyWvWLw/EVZSYEkZlQdkp0V39k/Xehs2Qa0mvoCvkVj3eQxNQ1Q==",
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "V8S3bsm50ig6JSyrbcJJ8bW2b9QLGouz+G1miK3UTaOWmMtFwNNNzUf4AleyDWUmTrWMLNnFSLEQtxmxgNQnNQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "7.0.0"
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "7.0.0",
"contentHash": "h3j/QfmFN4S0w4C2A6X7arXij/M/OVw3uQHSOFxnND4DyAzO1F9eMX7Eti7lU/OkSthEE0WzRsfT/Dmx86jzCw=="
"resolved": "8.0.0",
"contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "NSmDw3K0ozNDgShSIpsZcbFIzBX4w28nDag+TfaQujkXGazBm+lid5onlWoCBy4VsLxqnnKjEBbGSJVWJMf43g==",
"dependencies": {
"System.Text.Encodings.Web": "8.0.0",
"System.Text.Json": "8.0.0"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "7.0.0",
"contentHash": "Nw2muoNrOG5U5qa2ZekXwudUn2BJcD41e65zwmDHb1fQegTX66UokLWZkJRpqSSHXDOWZ5V0iqhbxOEky91atA==",
"resolved": "8.0.0",
"contentHash": "tvRkov9tAJ3xP51LCv3FJ2zINmv1P8Hi8lhhtcKGqM+ImiTCC84uOPEI4z8Cdq2C3o9e+Aa0Gw0rmrsJD77W+w==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "7.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "7.0.0",
"Microsoft.Extensions.Logging.Abstractions": "7.0.0",
"Microsoft.Extensions.Options": "7.0.0"
"Microsoft.Extensions.DependencyInjection": "8.0.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "7.0.0",
"contentHash": "kmn78+LPVMOWeITUjIlfxUPDsI0R6G0RkeAMBmQxAJ7vBJn4q2dTva7pWi65ceN5vPGjJ9q/Uae2WKgvfktJAw=="
"resolved": "8.0.0",
"contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "7.0.0",
"contentHash": "lP1yBnTTU42cKpMozuafbvNtQ7QcBjr/CcK3bYOGEMH55Fjt+iecXjT6chR7vbgCMqy3PG3aNQSZgo/EuY/9qQ==",
"resolved": "8.0.0",
"contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "7.0.0",
"Microsoft.Extensions.Primitives": "7.0.0"
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "7.0.0",
"contentHash": "um1KU5kxcRp3CNuI8o/GrZtD4AIOXDk+RLsytjZ9QPok3ttLUelLKpilVPuaFT3TFjOhSibUAso0odbOaCDj3Q=="
"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",
"contentHash": "BmAf6XWt4TqtowmiWe4/5rRot6GerAeklmOPfviOvwLoF5WwgxcJHAxZtySuyW9r9w+HLILnm8VfJFLCUJYW8A==",
"dependencies": {
"SQLitePCLRaw.lib.e_sqlite3": "2.1.6",
"SQLitePCLRaw.provider.e_sqlite3": "2.1.6"
}
},
"SQLitePCLRaw.core": {
"type": "Transitive",
"resolved": "2.1.6",
"contentHash": "wO6v9GeMx9CUngAet8hbO7xdm+M42p1XeJq47ogyRoYSvNSp0NGLI+MgC0bhrMk9C17MTVFlLiN6ylyExLCc5w==",
"dependencies": {
"System.Memory": "4.5.3"
}
},
"SQLitePCLRaw.lib.e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.6",
"contentHash": "2ObJJLkIUIxRpOUlZNGuD4rICpBnrBR5anjyfUFQep4hMOIeqW+XGQYzrNmHSVz5xSWZ3klSbh7sFR6UyDj68Q=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.6",
"contentHash": "PQ2Oq3yepLY4P7ll145P3xtx2bX8xF4PzaKPRpw9jZlKvfe4LE/saAV82inND9usn1XRpmxXk7Lal3MTI+6CNg==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.6"
}
},
"System.Memory": {
"type": "Transitive",
"resolved": "4.5.3",
"contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ=="
},
"System.Text.Json": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "OdrZO2WjkiEG6ajEFRABTRCi/wuXQPxeV6g8xvUJqdxMvvuCCEk86zPla8UiIQJz3durtUEbNyY/3lIhS0yZvQ==",
"dependencies": {
"System.Text.Encodings.Web": "8.0.0"
}
}
},
"net7.0-windows7.0/win-x64": {}
"net8.0-windows7.0/win-x64": {
"SQLitePCLRaw.lib.e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.6",
"contentHash": "2ObJJLkIUIxRpOUlZNGuD4rICpBnrBR5anjyfUFQep4hMOIeqW+XGQYzrNmHSVz5xSWZ3klSbh7sFR6UyDj68Q=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ=="
}
}
}
}