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, )" } },