From cafd943f29d2ebdac425d8ec5b0b1589cfb5b194 Mon Sep 17 00:00:00 2001
From: nxships <2096086+nxships@users.noreply.github.com>
Date: Mon, 3 Aug 2026 21:46:34 +0200
Subject: [PATCH] feat(internaldata): persist search comments and log changes
to history
Consumes NexusKit's new IInspectSearchCommentWatcher: when the user
examines somebody, SearchCommentCaptureService persists their search
comment and logs a history row if it actually moved.
The value goes on observed_player as search_comment, but is exposed via
the lazily-loaded ObservedPlayerDetail rather than the hot ObservedPlayer
record - same reasoning as the notes. ObservedPlayer holds every observed
row in memory at once, and this is a per-character free-text field the
list never renders.
The diff lives in the capture service, not in
InternalDataHistoryService.OnObservationProcessed, because the search
comment never travels through the observation pipeline - the prev/current
pair in PlayerObservationEvent simply does not carry it. SetSearchCommentAsync
already reads the previous value in order to write, so it hands it back and
the caller records the change without a second query.
Notes:
- PlayerHistoryKind.SearchCommentChange = 5. No migration: kind is INTEGER
and the enum is byte-backed for exactly this reason. OldValue/NewValue
carry raw text; either may be null, which is how "set for the first time"
and "cleared" are expressed.
- UpdatedAt is deliberately not bumped on write. It drives
observation-freshness, and examining somebody is not a sighting of them.
- Re-examining with an unchanged comment writes nothing and logs nothing.
- nexus_filter_player gains a search_comment column so the plugin can offer
user filters on it. The view is dropped and recreated on every startup,
so no migration is involved.
- NexusKit floors raised to [0.5.0,): NuGet resolves the LOWEST version
satisfying a range, so leaving them at [0.4.0,) would keep CI building
against a NexusKit without the watcher.
---
Directory.Packages.props | 8 +-
.../History/PlayerHistoryKind.cs | 12 +++
...InternalDataServiceCollectionExtensions.cs | 5 ++
.../Persistence/InternalDataEntityModule.cs | 1 +
.../Persistence/InternalDataMigrations.cs | 34 ++++++++
.../InternalObservedPlayerEntity.cs | 10 +++
.../Persistence/PlayerFilterViewBuilder.cs | 1 +
.../Players/IInternalDataPlayerWatcher.cs | 17 +++-
.../Players/InternalDataPlayerWatcher.cs | 35 +++++++-
.../Players/ObservedPlayerDetail.cs | 9 ++-
.../Players/SearchCommentCaptureService.cs | 79 +++++++++++++++++++
.../Players/SearchCommentUpdate.cs | 29 +++++++
NexusKit.Modules.InternalData/docs/history.md | 1 +
.../packages.lock.json | 1 +
.../packages.lock.json | 1 +
15 files changed, 234 insertions(+), 9 deletions(-)
create mode 100644 NexusKit.Modules.InternalData/Players/SearchCommentCaptureService.cs
create mode 100644 NexusKit.Modules.InternalData/Players/SearchCommentUpdate.cs
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 82f88b4..0a39d46 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -28,10 +28,10 @@
-
-
-
-
+
+
+
+
diff --git a/NexusKit.Modules.InternalData/History/PlayerHistoryKind.cs b/NexusKit.Modules.InternalData/History/PlayerHistoryKind.cs
index b74cffe..037fba0 100644
--- a/NexusKit.Modules.InternalData/History/PlayerHistoryKind.cs
+++ b/NexusKit.Modules.InternalData/History/PlayerHistoryKind.cs
@@ -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.
FreeCompanyChange = 4,
+
+ /// 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.
+ /// 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).
+ /// Not the Lodestone biography — that is a different datum and is not
+ /// tracked here.
+ SearchCommentChange = 5,
}
diff --git a/NexusKit.Modules.InternalData/InternalDataServiceCollectionExtensions.cs b/NexusKit.Modules.InternalData/InternalDataServiceCollectionExtensions.cs
index d0d9d65..b6199d6 100644
--- a/NexusKit.Modules.InternalData/InternalDataServiceCollectionExtensions.cs
+++ b/NexusKit.Modules.InternalData/InternalDataServiceCollectionExtensions.cs
@@ -36,6 +36,11 @@ public static IServiceCollection AddNexusKitInternalData(this IServiceCollection
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
+ // 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();
+
return services;
}
}
diff --git a/NexusKit.Modules.InternalData/Persistence/InternalDataEntityModule.cs b/NexusKit.Modules.InternalData/Persistence/InternalDataEntityModule.cs
index ce4514e..a7a8b51 100644
--- a/NexusKit.Modules.InternalData/Persistence/InternalDataEntityModule.cs
+++ b/NexusKit.Modules.InternalData/Persistence/InternalDataEntityModule.cs
@@ -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);
diff --git a/NexusKit.Modules.InternalData/Persistence/InternalDataMigrations.cs b/NexusKit.Modules.InternalData/Persistence/InternalDataMigrations.cs
index 02364ed..871b261 100644
--- a/NexusKit.Modules.InternalData/Persistence/InternalDataMigrations.cs
+++ b/NexusKit.Modules.InternalData/Persistence/InternalDataMigrations.cs
@@ -30,6 +30,7 @@ internal sealed class InternalDataMigrations : IMigrationModule
new AddEncountersDropSeenCount(),
new AddObservedPlayerNotesColumn(),
new AddEncounterWorldIdColumn(),
+ new AddObservedPlayerSearchCommentColumn(),
};
}
@@ -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";
diff --git a/NexusKit.Modules.InternalData/Persistence/InternalObservedPlayerEntity.cs b/NexusKit.Modules.InternalData/Persistence/InternalObservedPlayerEntity.cs
index 53a2693..5693981 100644
--- a/NexusKit.Modules.InternalData/Persistence/InternalObservedPlayerEntity.cs
+++ b/NexusKit.Modules.InternalData/Persistence/InternalObservedPlayerEntity.cs
@@ -50,5 +50,15 @@ public sealed class InternalObservedPlayerEntity
/// touch notes leave existing text alone.
public string? Notes { get; set; }
+ /// 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.
+ /// 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.
+ /// Do not confuse with the Lodestone biography, which lives on
+ /// PlayerProfileEntity.Bio in ExternalData.
+ public string? SearchComment { get; set; }
+
public DateTime UpdatedAt { get; set; }
}
diff --git a/NexusKit.Modules.InternalData/Persistence/PlayerFilterViewBuilder.cs b/NexusKit.Modules.InternalData/Persistence/PlayerFilterViewBuilder.cs
index 6ec9d77..d35fd95 100644
--- a/NexusKit.Modules.InternalData/Persistence/PlayerFilterViewBuilder.cs
+++ b/NexusKit.Modules.InternalData/Persistence/PlayerFilterViewBuilder.cs
@@ -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,
diff --git a/NexusKit.Modules.InternalData/Players/IInternalDataPlayerWatcher.cs b/NexusKit.Modules.InternalData/Players/IInternalDataPlayerWatcher.cs
index 9212b34..1866a6a 100644
--- a/NexusKit.Modules.InternalData/Players/IInternalDataPlayerWatcher.cs
+++ b/NexusKit.Modules.InternalData/Players/IInternalDataPlayerWatcher.cs
@@ -63,9 +63,20 @@ public interface IInternalDataPlayerWatcher
/// so notes saves don't shift the observation-freshness signal.
Task UpdateNotesAsync(ulong contentId, string? notes, CancellationToken ct = default);
+ /// 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 UpdatedAt alone for the same reason as
+ /// — examining somebody is not a sighting.
+ /// The returned reports whether the
+ /// value actually moved and what it was before, so the caller can write a
+ /// history row without a second read.
+ Task SetSearchCommentAsync(
+ ulong contentId, string? searchComment, CancellationToken ct = default);
+
/// Lazy-loads the heavy fields not carried on the in-memory
- /// : 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.
+ /// : 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.
Task GetDetailAsync(ulong contentId, CancellationToken ct = default);
}
diff --git a/NexusKit.Modules.InternalData/Players/InternalDataPlayerWatcher.cs b/NexusKit.Modules.InternalData/Players/InternalDataPlayerWatcher.cs
index 58ea9e7..aa482a9 100644
--- a/NexusKit.Modules.InternalData/Players/InternalDataPlayerWatcher.cs
+++ b/NexusKit.Modules.InternalData/Players/InternalDataPlayerWatcher.cs
@@ -527,6 +527,39 @@ public async Task UpdateNotesAsync(ulong contentId, string? notes, Cancell
return true;
}
+ public async Task 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()
+ .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 GetDetailAsync(ulong contentId, CancellationToken ct = default)
{
try
@@ -540,7 +573,7 @@ public async Task UpdateNotesAsync(ulong contentId, string? notes, Cancell
var row = await ctx.Set()
.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)
diff --git a/NexusKit.Modules.InternalData/Players/ObservedPlayerDetail.cs b/NexusKit.Modules.InternalData/Players/ObservedPlayerDetail.cs
index 9bc1a17..3d67834 100644
--- a/NexusKit.Modules.InternalData/Players/ObservedPlayerDetail.cs
+++ b/NexusKit.Modules.InternalData/Players/ObservedPlayerDetail.cs
@@ -12,7 +12,14 @@ namespace NexusKit.Modules.InternalData.Players;
/// hydrated from a lodestone-only source).
/// User-authored notes text. Null / empty when the user
/// hasn't written anything for this character.
+/// 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
+/// 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.
public sealed record ObservedPlayerDetail(
ulong ContentId,
byte[]? FullCustomize,
- string? Notes);
+ string? Notes,
+ string? SearchComment);
diff --git a/NexusKit.Modules.InternalData/Players/SearchCommentCaptureService.cs b/NexusKit.Modules.InternalData/Players/SearchCommentCaptureService.cs
new file mode 100644
index 0000000..36efc33
--- /dev/null
+++ b/NexusKit.Modules.InternalData/Players/SearchCommentCaptureService.cs
@@ -0,0 +1,79 @@
+using Microsoft.Extensions.Logging;
+using NexusKit.GameData.ObjectTables;
+using NexusKit.Modules.InternalData.History;
+
+namespace NexusKit.Modules.InternalData.Players;
+
+///
+/// Bridges the Examine-time search comment into the observation store and the
+/// change history. Subscribes to ,
+/// persists what came in, and logs a
+/// row when the value actually moved.
+/// The diff lives here rather than in
+/// InternalDataHistoryService.OnObservationProcessed because the search
+/// comment never travels through the observation pipeline — the prev/current
+/// pair in 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.
+/// Same shape as LiveTagChangeRefreshTrigger: a thin subscriber
+/// that owns one cross-cutting reaction and nothing else.
+///
+public sealed class SearchCommentCaptureService : IDisposable
+{
+ private readonly IInspectSearchCommentWatcher mSource;
+ private readonly IInternalDataPlayerWatcher mWatcher;
+ private readonly IInternalDataHistoryService mHistory;
+ private readonly ILogger mLog;
+ private bool mDisposed;
+
+ public SearchCommentCaptureService(
+ IInspectSearchCommentWatcher source,
+ IInternalDataPlayerWatcher watcher,
+ IInternalDataHistoryService history,
+ ILogger 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);
+ }
+ }
+}
diff --git a/NexusKit.Modules.InternalData/Players/SearchCommentUpdate.cs b/NexusKit.Modules.InternalData/Players/SearchCommentUpdate.cs
new file mode 100644
index 0000000..d5faad1
--- /dev/null
+++ b/NexusKit.Modules.InternalData/Players/SearchCommentUpdate.cs
@@ -0,0 +1,29 @@
+namespace NexusKit.Modules.InternalData.Players;
+
+///
+/// Outcome of .
+/// 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.
+///
+/// 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.
+/// 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.
+/// The value on file before the write. Null when there
+/// was none — that is the "search comment set for the first time" case.
+/// The value on file after the write.
+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);
+}
diff --git a/NexusKit.Modules.InternalData/docs/history.md b/NexusKit.Modules.InternalData/docs/history.md
index 9c4c21b..eb68317 100644
--- a/NexusKit.Modules.InternalData/docs/history.md
+++ b/NexusKit.Modules.InternalData/docs/history.md
@@ -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
diff --git a/NexusKit.Modules.InternalData/packages.lock.json b/NexusKit.Modules.InternalData/packages.lock.json
index 3fa0434..b826ed3 100644
--- a/NexusKit.Modules.InternalData/packages.lock.json
+++ b/NexusKit.Modules.InternalData/packages.lock.json
@@ -198,6 +198,7 @@
"nexuskit.gamedata": {
"type": "Project",
"dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "[10.0.10, )",
"NexusKit.Core": "[1.0.0, )"
}
},
diff --git a/NexusKit.Modules.PlayerEnrichment/packages.lock.json b/NexusKit.Modules.PlayerEnrichment/packages.lock.json
index 0e368f1..ab45fd5 100644
--- a/NexusKit.Modules.PlayerEnrichment/packages.lock.json
+++ b/NexusKit.Modules.PlayerEnrichment/packages.lock.json
@@ -260,6 +260,7 @@
"nexuskit.gamedata": {
"type": "Project",
"dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "[10.0.10, )",
"NexusKit.Core": "[1.0.0, )"
}
},