Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@
<!-- NexusKit framework. Open-ended ranges on purpose: the local workspace
swaps these for ProjectReferences, and CI takes the newest published
package. Do not let a bump turn them into exact pins. -->
<PackageVersion Include="NexusKit.Core" Version="[0.4.0,)" />
<PackageVersion Include="NexusKit.GameData" Version="[0.4.0,)" />
<PackageVersion Include="NexusKit.Persistence" Version="[0.4.0,)" />
<PackageVersion Include="NexusKit.Ui" Version="[0.4.0,)" />
<PackageVersion Include="NexusKit.Core" Version="[0.5.0,)" />
<PackageVersion Include="NexusKit.GameData" Version="[0.5.0,)" />
<PackageVersion Include="NexusKit.Persistence" Version="[0.5.0,)" />
<PackageVersion Include="NexusKit.Ui" Version="[0.5.0,)" />
</ItemGroup>

<ItemGroup>
Expand Down
12 changes: 12 additions & 0 deletions NexusKit.Modules.InternalData/History/PlayerHistoryKind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,16 @@ public enum PlayerHistoryKind : byte
/// tag-string OldValue/NewValue payloads on those rows render through
/// the same UI path; new rows carry FC Lodestone ids.</para></summary>
FreeCompanyChange = 4,

/// <summary>The character's in-game search comment (Search Info) changed.
/// OldValue / NewValue carry the raw text; either may be null, which is how
/// "set for the first time" and "cleared" are expressed.
/// <para>Unlike every kind above, this one is not produced by the
/// observation diff — the search comment is not in the object table. It is
/// written by the Examine capture path, so a row only ever appears for a
/// character the user examined at least twice with a different comment in
/// between (or once, if they had none on file before).</para>
/// <para>Not the Lodestone biography — that is a different datum and is not
/// tracked here.</para></summary>
SearchCommentChange = 5,
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ public static IServiceCollection AddNexusKitInternalData(this IServiceCollection
services.AddSingleton<InternalDataEncounterTracker>();
services.AddSingleton<IInternalDataEncounterTracker>(sp => sp.GetRequiredService<InternalDataEncounterTracker>());

// Examine-time search-comment capture. Needs an eager resolve like the
// watcher — its ctor is where the subscription happens. Depends on
// IInspectSearchCommentWatcher from NexusKit.GameData.
services.AddSingleton<SearchCommentCaptureService>();

return services;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public void ConfigureEntities(ModelBuilder modelBuilder)
e.Property(x => x.CurrentMinionId).HasColumnName("current_minion_id");
e.Property(x => x.OnlineStatusId).HasColumnName("online_status_id");
e.Property(x => x.Notes).HasColumnName("notes");
e.Property(x => x.SearchComment).HasColumnName("search_comment");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");

e.HasIndex(x => x.Name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ internal sealed class InternalDataMigrations : IMigrationModule
new AddEncountersDropSeenCount(),
new AddObservedPlayerNotesColumn(),
new AddEncounterWorldIdColumn(),
new AddObservedPlayerSearchCommentColumn(),
};
}

Expand Down Expand Up @@ -222,6 +223,39 @@ await ctx.Database.ExecuteSqlRawAsync(
}
}

internal sealed class AddObservedPlayerSearchCommentColumn : IMigration
{
public string Id => "20260803_observed_player_search_comment_column";

// Same shape as the notes column above: declared on the entity module, so
// fresh installs get it from EnsureCreated and this is stamped applied on
// baseline. Upgrade installs take the ALTER TABLE, PRAGMA-gated for
// idempotence. Existing rows stay NULL — the search comment only ever
// arrives via the Examine capture path, never from an observation tick,
// so there is nothing to backfill from.
public async Task UpAsync(DbContext ctx, CancellationToken ct)
{
var connection = ctx.Database.GetDbConnection();
if (connection.State != System.Data.ConnectionState.Open)
await connection.OpenAsync(ct).ConfigureAwait(false);

await using (var probe = connection.CreateCommand())
{
probe.CommandText = "PRAGMA table_info(nexus_internal_observed_player);";
await using var reader = await probe.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
if (string.Equals(reader.GetString(1), "search_comment", StringComparison.OrdinalIgnoreCase))
return;
}
}

await ctx.Database.ExecuteSqlRawAsync(
"ALTER TABLE nexus_internal_observed_player ADD COLUMN search_comment TEXT;",
ct).ConfigureAwait(false);
}
}

internal sealed class AddObservedPlayerLastSeenIndex : IMigration
{
public string Id => "20260515_observed_player_last_seen_idx";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,15 @@ public sealed class InternalObservedPlayerEntity
/// touch notes leave existing text alone.</summary>
public string? Notes { get; set; }

/// <summary>The character's in-game search comment (Search Info), captured
/// when the user examined them. Null when never captured or when the
/// character has none set — the two are deliberately not distinguished.
/// <para>Not part of the observation tick: the game's Character struct
/// carries no such field, so this is only ever written by the Examine
/// capture path, never by the object-table scan.</para>
/// <para>Do not confuse with the Lodestone biography, which lives on
/// <c>PlayerProfileEntity.Bio</c> in ExternalData.</para></summary>
public string? SearchComment { get; set; }

public DateTime UpdatedAt { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ CREATE VIEW {ViewName} AS
o.online_status_id AS online_status_id,
o.company_tag AS company_tag,
o.notes AS notes,
o.search_comment AS search_comment,
e.data_center_id AS external_data_center_id,
p.free_company_lodestone_id AS free_company_lodestone_id,
fc.name AS fc_name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,20 @@ public interface IInternalDataPlayerWatcher
/// so notes saves don't shift the observation-freshness signal.</summary>
Task<bool> UpdateNotesAsync(ulong contentId, string? notes, CancellationToken ct = default);

/// <summary>Persists the character's in-game search comment, captured by the
/// Examine path rather than by an observation tick (the game's Character
/// struct carries no such field). Pass null or whitespace to record that they
/// have none. Leaves <c>UpdatedAt</c> alone for the same reason as
/// <see cref="UpdateNotesAsync"/> — examining somebody is not a sighting.
/// <para>The returned <see cref="SearchCommentUpdate"/> reports whether the
/// value actually moved and what it was before, so the caller can write a
/// history row without a second read.</para></summary>
Task<SearchCommentUpdate> SetSearchCommentAsync(
ulong contentId, string? searchComment, CancellationToken ct = default);

/// <summary>Lazy-loads the heavy fields not carried on the in-memory
/// <see cref="ObservedPlayer"/>: full Customize bytes and Notes content.
/// Single indexed lookup; safe to call from the UI thread. Returns null
/// when no observed_player row exists for the id.</summary>
/// <see cref="ObservedPlayer"/>: full Customize bytes, Notes content and the
/// captured search comment. Single indexed lookup; safe to call from the UI
/// thread. Returns null when no observed_player row exists for the id.</summary>
Task<ObservedPlayerDetail?> GetDetailAsync(ulong contentId, CancellationToken ct = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,39 @@ public async Task<bool> UpdateNotesAsync(ulong contentId, string? notes, Cancell
return true;
}

public async Task<SearchCommentUpdate> SetSearchCommentAsync(
ulong contentId, string? searchComment, CancellationToken ct = default)
{
var normalized = string.IsNullOrWhiteSpace(searchComment) ? null : searchComment.Trim();
try
{
await using var ctx = await mDb.CreateDbContextAsync(ct).ConfigureAwait(false);
var row = await ctx.Set<InternalObservedPlayerEntity>()
.FindAsync(new object[] { contentId }, ct).ConfigureAwait(false);
// No observation row means we never saw this character on the object
// table. Nothing to attach the comment to, and inventing a row here
// would fabricate a sighting that never happened.
if (row is null) return SearchCommentUpdate.NotApplied;

var previous = row.SearchComment;
if (string.Equals(previous, normalized, StringComparison.Ordinal))
return SearchCommentUpdate.Unchanged(previous);

row.SearchComment = normalized;
// UpdatedAt deliberately left alone, same as UpdateNotesAsync: it
// drives observation-freshness, and examining somebody is not a
// sighting of them.
await ctx.SaveChangesAsync(ct).ConfigureAwait(false);
return SearchCommentUpdate.Changed(previous, normalized);
}
catch (OperationCanceledException) { return SearchCommentUpdate.NotApplied; }
catch (Exception ex)
{
mLog.LogWarning(ex, "InternalData: failed to persist search comment for ContentId {Cid}", contentId);
return SearchCommentUpdate.NotApplied;
}
}

public async Task<ObservedPlayerDetail?> GetDetailAsync(ulong contentId, CancellationToken ct = default)
{
try
Expand All @@ -540,7 +573,7 @@ public async Task<bool> UpdateNotesAsync(ulong contentId, string? notes, Cancell
var row = await ctx.Set<InternalObservedPlayerEntity>()
.FindAsync(new object[] { contentId }, ct).ConfigureAwait(false);
if (row is null) return null;
return new ObservedPlayerDetail(contentId, row.Customize, row.Notes);
return new ObservedPlayerDetail(contentId, row.Customize, row.Notes, row.SearchComment);
}
catch (OperationCanceledException) { return null; }
catch (Exception ex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ namespace NexusKit.Modules.InternalData.Players;
/// hydrated from a lodestone-only source).</param>
/// <param name="Notes">User-authored notes text. Null / empty when the user
/// hasn't written anything for this character.</param>
/// <param name="SearchComment">The character's own in-game search comment,
/// captured the last time the user examined them. Null when never captured or
/// when they have none set. Sits here rather than on <see cref="ObservedPlayer"/>
/// for the same reason as the notes: it is a per-character free-text field the
/// list never renders, and the hot record holds every observed row at once.
/// Not the Lodestone biography.</param>
public sealed record ObservedPlayerDetail(
ulong ContentId,
byte[]? FullCustomize,
string? Notes);
string? Notes,
string? SearchComment);
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using Microsoft.Extensions.Logging;
using NexusKit.GameData.ObjectTables;
using NexusKit.Modules.InternalData.History;

namespace NexusKit.Modules.InternalData.Players;

/// <summary>
/// Bridges the Examine-time search comment into the observation store and the
/// change history. Subscribes to <see cref="IInspectSearchCommentWatcher"/>,
/// persists what came in, and logs a <see cref="PlayerHistoryKind.SearchCommentChange"/>
/// row when the value actually moved.
/// <para>The diff lives here rather than in
/// <c>InternalDataHistoryService.OnObservationProcessed</c> because the search
/// comment never travels through the observation pipeline — the prev/current
/// pair in <see cref="PlayerObservationEvent"/> simply does not carry it. The
/// write already reads the previous value, so it hands it back and this class
/// records the change without a second query.</para>
/// <para>Same shape as <c>LiveTagChangeRefreshTrigger</c>: a thin subscriber
/// that owns one cross-cutting reaction and nothing else.</para>
/// </summary>
public sealed class SearchCommentCaptureService : IDisposable
{
private readonly IInspectSearchCommentWatcher mSource;
private readonly IInternalDataPlayerWatcher mWatcher;
private readonly IInternalDataHistoryService mHistory;
private readonly ILogger<SearchCommentCaptureService> mLog;
private bool mDisposed;

public SearchCommentCaptureService(
IInspectSearchCommentWatcher source,
IInternalDataPlayerWatcher watcher,
IInternalDataHistoryService history,
ILogger<SearchCommentCaptureService> log)
{
mSource = source;
mWatcher = watcher;
mHistory = history;
mLog = log;

mSource.SearchCommentReceived += OnSearchCommentReceived;
}

public void Dispose()
{
if (mDisposed) return;
mDisposed = true;
mSource.SearchCommentReceived -= OnSearchCommentReceived;
}

private void OnSearchCommentReceived(ulong contentId, string? comment)
{
// Fires on the framework thread — get off it before touching the DB.
_ = Task.Run(() => CaptureAsync(contentId, comment));
}

private async Task CaptureAsync(ulong contentId, string? comment)
{
try
{
var result = await mWatcher.SetSearchCommentAsync(contentId, comment).ConfigureAwait(false);
if (!result.Applied || !result.ValueChanged) return;

// A first capture on a character we have never examined before is
// indistinguishable from them having just written the comment, so it
// is recorded as "set" either way. That is the honest reading: all we
// can say is that this is the first value we know of.
await mHistory.InsertIfNewAsync(
contentId,
PlayerHistoryKind.SearchCommentChange,
DateTime.UtcNow,
result.Previous,
result.Current).ConfigureAwait(false);
}
catch (Exception ex)
{
mLog.LogWarning(ex, "InternalData: search-comment capture failed for ContentId {Cid}", contentId);
}
}
}
29 changes: 29 additions & 0 deletions NexusKit.Modules.InternalData/Players/SearchCommentUpdate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace NexusKit.Modules.InternalData.Players;

/// <summary>
/// Outcome of <see cref="IInternalDataPlayerWatcher.SetSearchCommentAsync"/>.
/// Carries the previous value so the caller can log a history row without a
/// second read — the write already had the old value in hand.
/// </summary>
/// <param name="Applied">False when nothing was written: no observation row for
/// this character, or the write failed. Distinct from a no-op write, where the
/// value simply already matched.</param>
/// <param name="ValueChanged">True only when the stored value actually moved.
/// Re-examining somebody whose comment is unchanged lands here as false, which
/// is what keeps repeat examines out of the history.</param>
/// <param name="Previous">The value on file before the write. Null when there
/// was none — that is the "search comment set for the first time" case.</param>
/// <param name="Current">The value on file after the write.</param>
public readonly record struct SearchCommentUpdate(
bool Applied,
bool ValueChanged,
string? Previous,
string? Current)
{
public static SearchCommentUpdate NotApplied => new(false, false, null, null);

public static SearchCommentUpdate Unchanged(string? value) => new(true, false, value, value);

public static SearchCommentUpdate Changed(string? previous, string? current)
=> new(true, true, previous, current);
}
1 change: 1 addition & 0 deletions NexusKit.Modules.InternalData/docs/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ diffs `(Previous, Current)` on every upsert.
| 2 | `HomeWorldChange` | `prev.HomeWorld != curr.HomeWorld`. The watcher stores the localised name, so this works regardless of plugin culture switches as long as both compare in the same locale. |
| 3 | `CustomizeChange` | Race byte (`Customize[0]`) or gender byte (`Customize[1]`) differ. Hair / face / colour bytes are intentionally **not** tracked — they'd flood the timeline. Race/gender map to Fantasia-grade changes. |
| 4 | `FreeCompanyChange` | `external_player_profile.free_company_lodestone_id` changes during a Lodestone refresh. Detected in `ExternalDataPlayerService.UpsertProfileAsync`, which awaits an `IExternalDataFreeCompanyService.GetAsync` on the new FC id before invoking the change recorder so the FC catalog row is in cache by the time `HistoryNotificationProducer` resolves it for the chat line (the refresh queue's per-category interleave would otherwise leave the catalog row missing for ≥1 minute). The live object-table tag is never used as a signal here. |
| 5 | `SearchCommentChange` | The character's in-game search comment differs from the stored one. **Not** an observation diff — the search comment is not on the game's `Character` struct and never reaches `ObservationProcessed`. Written by `SearchCommentCaptureService`, which listens to `IInspectSearchCommentWatcher` (a hook on `AgentInspect.ReceiveSearchComment`) and therefore only ever sees characters the user examined. `OldValue` / `NewValue` carry the raw text; either may be null, which is how "set for the first time" and "cleared" are expressed. Not the Lodestone biography. |

Each row stores pre-formatted display strings in `OldValue` / `NewValue`
(e.g. `"Hyur · Female"` for a customize change) so the UI doesn't have to
Expand Down
1 change: 1 addition & 0 deletions NexusKit.Modules.InternalData/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@
"nexuskit.gamedata": {
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.10, )",
"NexusKit.Core": "[1.0.0, )"
}
},
Expand Down
1 change: 1 addition & 0 deletions NexusKit.Modules.PlayerEnrichment/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@
"nexuskit.gamedata": {
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.10, )",
"NexusKit.Core": "[1.0.0, )"
}
},
Expand Down