diff --git a/Directory.Packages.props b/Directory.Packages.props index fefa37a..ebee2ea 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,6 +25,11 @@ + + + diff --git a/src/SharpMud.Engine/Commands/Builtin/Admin/HelpAdminCommands.cs b/src/SharpMud.Engine/Commands/Builtin/Admin/HelpAdminCommands.cs new file mode 100644 index 0000000..d65980c --- /dev/null +++ b/src/SharpMud.Engine/Commands/Builtin/Admin/HelpAdminCommands.cs @@ -0,0 +1,24 @@ +using SharpMud.Engine.Help; + +namespace SharpMud.Engine.Commands.Builtin.Admin; + +/// +/// Registers the help-authoring command set (ADR-0010) - helptopic/ +/// helpindex, both at (same +/// tier as world-building/OLC - authoring content is the same class of +/// privilege). Not called automatically by - a consumer calls this themselves, +/// the same opt-in shape / +/// already use. help +/// itself (topic lookup) is always registered by +/// - only authoring is opt-in. +/// +public static class HelpAdminCommands +{ + /// Registers helptopic/helpindex against . + public static void RegisterAll(ICommandRegistry registry, IHelpRepository repository, IEmbeddingProvider embeddingProvider) + { + registry.RegisterWithRole(new HelpTopicEditCommand(repository), SecurityRole.MinorBuilder); + registry.RegisterWithRole(new HelpIndexRebuildCommand(repository, embeddingProvider), SecurityRole.MinorBuilder); + } +} diff --git a/src/SharpMud.Engine/Commands/Builtin/Admin/HelpIndexRebuildCommand.cs b/src/SharpMud.Engine/Commands/Builtin/Admin/HelpIndexRebuildCommand.cs new file mode 100644 index 0000000..66beb24 --- /dev/null +++ b/src/SharpMud.Engine/Commands/Builtin/Admin/HelpIndexRebuildCommand.cs @@ -0,0 +1,59 @@ +using SharpMud.Engine.Help; + +namespace SharpMud.Engine.Commands.Builtin.Admin; + +/// +/// The helpindex rebuild command (, +/// ADR-0010) - re-chunks and re-embeds every 's via , replacing +/// each topic's wholesale. No automatic +/// trigger exists (not on save, not at boot) - this command is the only way +/// the semantic-search index changes, deliberately keeping embedding-provider +/// calls out of the content-edit path (ADR-0010's Decision Outcome). +/// +public sealed class HelpIndexRebuildCommand : ICommand +{ + private readonly IHelpRepository _repository; + private readonly IEmbeddingProvider _embeddingProvider; + + /// Creates the command, rebuilding topics from via . + public HelpIndexRebuildCommand(IHelpRepository repository, IEmbeddingProvider embeddingProvider) + { + _repository = repository; + _embeddingProvider = embeddingProvider; + } + + /// + public string Verb => "helpindex"; + + /// + public IReadOnlyList Aliases { get; } = []; + + /// + public async Task ExecuteAsync(CommandContext ctx, CancellationToken ct) + { + if (ctx.Args is not ["rebuild"]) + { + await ctx.Session.WriteLineAsync("Usage: helpindex rebuild", ct); + return; + } + + var topics = await _repository.GetAllTopicsAsync(ct); + foreach (var topic in topics) + { + var chunkTexts = HelpTopicChunker.Split(topic.Body); + var chunks = new List(chunkTexts.Count); + for (var i = 0; i < chunkTexts.Count; i++) + { + var embedding = await _embeddingProvider.EmbedAsync(chunkTexts[i], ct); + chunks.Add(new HelpTopicChunk(Guid.CreateVersion7(), topic.Id, i, chunkTexts[i], embedding, _embeddingProvider.ModelId, topic.ContentHash)); + } + + topic.ReplaceChunks(chunks); + await _repository.SaveTopicAsync(topic, ct); + } + + var chunkCount = topics.Sum(t => t.Chunks.Count); + await ctx.Session.WriteLineAsync($"Rebuilt the help index: {topics.Count} topic(s), {chunkCount} chunk(s).", ct); + } +} diff --git a/src/SharpMud.Engine/Commands/Builtin/Admin/HelpTopicEditCommand.cs b/src/SharpMud.Engine/Commands/Builtin/Admin/HelpTopicEditCommand.cs new file mode 100644 index 0000000..fda4d49 --- /dev/null +++ b/src/SharpMud.Engine/Commands/Builtin/Admin/HelpTopicEditCommand.cs @@ -0,0 +1,63 @@ +using SharpMud.Engine.Help; + +namespace SharpMud.Engine.Commands.Builtin.Admin; + +/// +/// The helptopic <key> <body> command (, ADR-0010) - creates a new +/// or overwrites an existing one's . An existing topic is found the same way +/// lookup works ( - +/// exact, case-insensitive match against or +/// an alias), but only is ever overwritten - +/// is only set on the create-new-topic path, +/// never reassigned on an edit. This is the only authoring path for help +/// content - no file-based alternative (see ADR-0010's Decision Outcome). +/// Aliases/keywords aren't settable via this command yet - deliberately out +/// of v1 scope, see PLAN-0010's Open questions. +/// +public sealed class HelpTopicEditCommand : ICommand +{ + private readonly IHelpRepository _repository; + + /// Creates the command, saving through . + public HelpTopicEditCommand(IHelpRepository repository) + { + _repository = repository; + } + + /// + public string Verb => "helptopic"; + + /// + public IReadOnlyList Aliases { get; } = []; + + /// + public async Task ExecuteAsync(CommandContext ctx, CancellationToken ct) + { + if (ctx.Args.Count < 2) + { + await ctx.Session.WriteLineAsync("Usage: helptopic ", ct); + return; + } + + var key = ctx.Args[0]; + var body = string.Join(' ', ctx.Args.Skip(1)); + + // Key is only ever set here on the create-new-topic path (via the + // object initializer below) - an existing topic keeps its + // canonical Key even though the lookup that found it is + // case-insensitive and (eventually) alias-aware. Reassigning Key + // unconditionally here would let `helptopic Wizard ...` silently + // rename a `wizard` topic's stored casing today, and once aliases + // are settable, editing by an alias would rename the canonical Key + // to the alias text - caught in PR review. + var topic = await _repository.FindByNameOrAliasAsync(key, ct) ?? new HelpTopic { Id = HelpTopicId.New(), Key = key }; + topic.Body = body; + topic.Touch(); + + await _repository.SaveTopicAsync(topic, ct); + + await ctx.Session.WriteLineAsync($"Help topic '{key}' saved.", ct); + } +} diff --git a/src/SharpMud.Engine/Commands/Builtin/BuiltinCommands.cs b/src/SharpMud.Engine/Commands/Builtin/BuiltinCommands.cs index bf909cd..5bfdf76 100644 --- a/src/SharpMud.Engine/Commands/Builtin/BuiltinCommands.cs +++ b/src/SharpMud.Engine/Commands/Builtin/BuiltinCommands.cs @@ -1,4 +1,5 @@ using SharpMud.Engine.Behaviors; +using SharpMud.Engine.Help; namespace SharpMud.Engine.Commands.Builtin; @@ -13,7 +14,7 @@ namespace SharpMud.Engine.Commands.Builtin; // themselves" visibly separate. public static class BuiltinCommands { - public static void RegisterAll(ICommandRegistry registry) + public static void RegisterAll(ICommandRegistry registry, IHelpRepository helpRepository, IHelpSearchIndex helpSearchIndex) { registry.RegisterOpen(new MoveCommand(Direction.North, "north", ["n"])); registry.RegisterOpen(new MoveCommand(Direction.South, "south", ["s"])); @@ -40,6 +41,6 @@ public static void RegisterAll(ICommandRegistry registry) registry.RegisterOpen(new RemoveCommand()); registry.RegisterOpen(new InventoryCommand()); registry.RegisterOpen(new GiveCommand()); - registry.RegisterOpen(new HelpCommand(registry)); + registry.RegisterOpen(new HelpCommand(registry, helpRepository, helpSearchIndex)); } } diff --git a/src/SharpMud.Engine/Commands/Builtin/HelpCommand.cs b/src/SharpMud.Engine/Commands/Builtin/HelpCommand.cs index a2a0785..a7b7e4a 100644 --- a/src/SharpMud.Engine/Commands/Builtin/HelpCommand.cs +++ b/src/SharpMud.Engine/Commands/Builtin/HelpCommand.cs @@ -1,18 +1,92 @@ using SharpMud.Engine.Behaviors; +using SharpMud.Engine.Help; namespace SharpMud.Engine.Commands.Builtin; -public sealed class HelpCommand(ICommandRegistry registry) : ICommand +/// +/// The help command. With no arguments, lists every command the +/// actor can see (unchanged behavior). With an argument, resolves a +/// via ADR-0010's three-tier pipeline: exact +/// / match, then +/// match, then +/// semantic search - falling back to "no help topic found" only once all +/// three miss, never a weak guess. +/// +public sealed class HelpCommand : ICommand { + private readonly ICommandRegistry _registry; + private readonly IHelpRepository _helpRepository; + private readonly IHelpSearchIndex _helpSearchIndex; + + /// Creates a help command listing commands from and resolving topics via /. + public HelpCommand(ICommandRegistry registry, IHelpRepository helpRepository, IHelpSearchIndex helpSearchIndex) + { + _registry = registry; + _helpRepository = helpRepository; + _helpSearchIndex = helpSearchIndex; + } + + /// public string Verb => "help"; + + /// public IReadOnlyList Aliases { get; } = []; + /// public async Task ExecuteAsync(CommandContext ctx, CancellationToken ct) + { + if (ctx.Args.Count > 0) + { + await ExecuteTopicLookupAsync(ctx, ct); + return; + } + + await ExecuteCommandListingAsync(ctx, ct); + } + + private async Task ExecuteTopicLookupAsync(CommandContext ctx, CancellationToken ct) + { + var query = string.Join(' ', ctx.Args); + + var topic = await _helpRepository.FindByNameOrAliasAsync(query, ct); + + if (topic is null) + { + // FindByKeywordAsync's contract is an exact match against one + // Keywords entry, not a substring/full-phrase match - so a + // multi-word query has to be checked one token at a time, or a + // query like "teach me magic" could never hit a "magic" + // keyword. First token to match wins - caught in PR review. + foreach (var token in query.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + var keywordMatches = await _helpRepository.FindByKeywordAsync(token, ct); + topic = keywordMatches.FirstOrDefault(); + if (topic is not null) + break; + } + } + + if (topic is null) + { + var hits = await _helpSearchIndex.SearchAsync(query, ct); + topic = hits.FirstOrDefault()?.Topic; + } + + if (topic is null) + { + await ctx.Session.WriteLineAsync($"No help topic found for '{query}'.", ct); + return; + } + + await ctx.Session.WriteLineAsync(topic.Body, ct); + } + + private async Task ExecuteCommandListingAsync(CommandContext ctx, CancellationToken ct) { await ctx.Session.WriteLineAsync("Available commands:", ct); var actorRoles = ctx.Actor.FindBehavior()?.Roles ?? SecurityRole.None; - foreach (var command in registry.Commands.OrderBy(c => c.Verb, StringComparer.Ordinal)) + foreach (var command in _registry.Commands.OrderBy(c => c.Verb, StringComparer.Ordinal)) { // RoleGuardedCommand passes Verb/Aliases straight through from // the command it wraps, so without this check every admin diff --git a/src/SharpMud.Engine/Help/CosineHelpSearchIndex.cs b/src/SharpMud.Engine/Help/CosineHelpSearchIndex.cs new file mode 100644 index 0000000..43b4ab8 --- /dev/null +++ b/src/SharpMud.Engine/Help/CosineHelpSearchIndex.cs @@ -0,0 +1,65 @@ +namespace SharpMud.Engine.Help; + +/// +/// Brute-force cosine-similarity (ADR-0010) - +/// loads every / through +/// and compares in app code; no vector-search +/// dependency. Fine at help-topic corpus scale (dozens-hundreds of topics); +/// revisit (e.g. an ANN-backed index) if that scale ever changes - see +/// ADR-0010's Negative Consequences. +/// +public sealed class CosineHelpSearchIndex : IHelpSearchIndex +{ + private readonly IHelpRepository _repository; + private readonly IEmbeddingProvider _embeddingProvider; + + /// Creates a search index reading topics through and embedding queries via . + public CosineHelpSearchIndex(IHelpRepository repository, IEmbeddingProvider embeddingProvider) + { + _repository = repository; + _embeddingProvider = embeddingProvider; + } + + /// + public async Task> SearchAsync(string query, CancellationToken ct) + { + var queryVector = await _embeddingProvider.EmbedAsync(query, ct); + var topics = await _repository.GetAllTopicsAsync(ct); + var relevanceThreshold = _embeddingProvider.RelevanceThreshold; + + var bestByTopic = new Dictionary(); + foreach (var topic in topics) + { + foreach (var chunk in topic.Chunks) + { + var score = CosineSimilarity(queryVector, chunk.Embedding); + if (score < relevanceThreshold) + continue; + + if (!bestByTopic.TryGetValue(topic.Id, out var existing) || score > existing.Score) + bestByTopic[topic.Id] = new HelpSearchHit(topic, score); + } + } + + return bestByTopic.Values.OrderByDescending(hit => hit.Score).ToList(); + } + + private static double CosineSimilarity(float[] a, float[] b) + { + if (a.Length != b.Length) + return 0; + + double dot = 0, magnitudeA = 0, magnitudeB = 0; + for (var i = 0; i < a.Length; i++) + { + dot += a[i] * b[i]; + magnitudeA += a[i] * a[i]; + magnitudeB += b[i] * b[i]; + } + + if (magnitudeA == 0 || magnitudeB == 0) + return 0; + + return dot / (Math.Sqrt(magnitudeA) * Math.Sqrt(magnitudeB)); + } +} diff --git a/src/SharpMud.Engine/Help/HelpContentHashing.cs b/src/SharpMud.Engine/Help/HelpContentHashing.cs new file mode 100644 index 0000000..e90e28c --- /dev/null +++ b/src/SharpMud.Engine/Help/HelpContentHashing.cs @@ -0,0 +1,13 @@ +using System.Security.Cryptography; +using System.Text; + +namespace SharpMud.Engine.Help; + +// Shared by HelpTopic.ContentHash and anything comparing it against a +// HelpTopicChunk.SourceContentHash - kept as one static function rather than +// duplicated so the exact hash inputs/encoding can't drift between the two. +internal static class HelpContentHashing +{ + public static string Compute(string text) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text))); +} diff --git a/src/SharpMud.Engine/Help/HelpSearchHit.cs b/src/SharpMud.Engine/Help/HelpSearchHit.cs new file mode 100644 index 0000000..1c11d8c --- /dev/null +++ b/src/SharpMud.Engine/Help/HelpSearchHit.cs @@ -0,0 +1,6 @@ +namespace SharpMud.Engine.Help; + +/// One result - a matched topic and its relevance score. +/// The matched topic. +/// Cosine similarity in [-1, 1] (in practice [0, 1] for non-negative embeddings) between the query and this topic's best-matching chunk. +public sealed record HelpSearchHit(HelpTopic Topic, double Score); diff --git a/src/SharpMud.Engine/Help/HelpTopic.cs b/src/SharpMud.Engine/Help/HelpTopic.cs new file mode 100644 index 0000000..69f8c18 --- /dev/null +++ b/src/SharpMud.Engine/Help/HelpTopic.cs @@ -0,0 +1,69 @@ +namespace SharpMud.Engine.Help; + +/// +/// A help topic - the aggregate root of sharp-mud's help system (ADR-0010). +/// Independently addressable, own repository (), +/// same "aggregate root gets exactly one repository" shape as Thing/ +/// IThingRepository - not a Thing/Behavior itself. +/// Authored and edited only via an in-game admin command; there is no +/// file-based content path (see ADR-0010's Decision Outcome for why, and +/// docs/research/wheelmud-findings.md §12 for the WheelMUD precedent +/// this deliberately deviates from). +/// +public sealed class HelpTopic +{ + private readonly List _aliases = []; + private readonly List _keywords = []; + private readonly List _chunks = []; + + /// Stable identity, independent of (which can be renamed via edit). + public required HelpTopicId Id { get; init; } + + /// The canonical, primary name this topic is looked up by - e.g. help wizard. Editable directly, like Thing.Name/Thing.Description (both plain settable properties, the established shape for admin-command-edited content in this codebase). + public required string Key { get; set; } + + /// Free-text grouping, e.g. "classes". Empty string means uncategorized. + public string Category { get; set; } = ""; + + /// The authored help text shown to a player on lookup - the canonical source of truth (ADR-0010); s are a rebuildable derivative of this, never the reverse. + public string Body { get; set; } = ""; + + /// When ///aliases/keywords last changed. Only mutated via . + public DateTimeOffset UpdatedAtUtc { get; private set; } + + /// Additional names this topic is looked up by, besides . Mutate via . + public IReadOnlyList Aliases => _aliases; + + /// Free-text keywords this topic matches on the keyword-lookup tier (ADR-0010's pipeline, tier two). Mutate via . + public IReadOnlyList Keywords => _keywords; + + /// This topic's current embedding chunks - a rebuildable index over , not authoritative content. Replaced wholesale via , never edited individually. + public IReadOnlyList Chunks => _chunks; + + /// SHA-256 hex digest of - compared against each to detect a stale embedding index. Derived, not persisted directly. + public string ContentHash => HelpContentHashing.Compute(Body); + + /// Replaces this topic's alias set wholesale. + public void SetAliases(IEnumerable aliases) + { + _aliases.Clear(); + _aliases.AddRange(aliases); + } + + /// Replaces this topic's keyword set wholesale. + public void SetKeywords(IEnumerable keywords) + { + _keywords.Clear(); + _keywords.AddRange(keywords); + } + + /// Records that this topic's content changed just now - called by the admin edit command after making its changes. + public void Touch() => UpdatedAtUtc = DateTimeOffset.UtcNow; + + /// Replaces this topic's wholesale - called by helpindex rebuild. Chunks are always regenerated in full from the current , never patched individually. + public void ReplaceChunks(IEnumerable chunks) + { + _chunks.Clear(); + _chunks.AddRange(chunks); + } +} diff --git a/src/SharpMud.Engine/Help/HelpTopicChunk.cs b/src/SharpMud.Engine/Help/HelpTopicChunk.cs new file mode 100644 index 0000000..1e8c864 --- /dev/null +++ b/src/SharpMud.Engine/Help/HelpTopicChunk.cs @@ -0,0 +1,23 @@ +namespace SharpMud.Engine.Help; + +/// +/// One embedded slice of a 's +/// (ADR-0010) - a value object, always regenerated wholesale by helpindex +/// rebuild (see ), never edited in +/// place. +/// +/// Row identity - needed only because it's its own persisted table row, not a domain concept callers reason about. +/// The owning . +/// Position within the topic's chunk sequence. +/// The chunk's source text, as embedded. +/// The embedding vector produced by for . +/// Which produced - lets a future rebuild detect a model change, not just a content change. +/// The owning topic's at embed time - compared against the topic's current hash to detect a stale index. +public sealed record HelpTopicChunk( + Guid Id, + HelpTopicId HelpTopicId, + int ChunkIndex, + string Text, + float[] Embedding, + string EmbeddingModelId, + string SourceContentHash); diff --git a/src/SharpMud.Engine/Help/HelpTopicChunker.cs b/src/SharpMud.Engine/Help/HelpTopicChunker.cs new file mode 100644 index 0000000..56fa5e9 --- /dev/null +++ b/src/SharpMud.Engine/Help/HelpTopicChunker.cs @@ -0,0 +1,22 @@ +namespace SharpMud.Engine.Help; + +/// +/// Splits a into per-paragraph chunks (blank +/// lines as boundaries) - used by helpindex rebuild to decide what +/// gets embedded. A static function, not a service, since it's pure text +/// splitting with no dependency. +/// +public static class HelpTopicChunker +{ + /// Splits on blank lines into non-empty, trimmed paragraphs. Empty/whitespace-only input yields no chunks. + public static IReadOnlyList Split(string body) + { + if (string.IsNullOrWhiteSpace(body)) + return []; + + return body + .Split("\n\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(p => p.Length > 0) + .ToList(); + } +} diff --git a/src/SharpMud.Engine/Help/HelpTopicId.cs b/src/SharpMud.Engine/Help/HelpTopicId.cs new file mode 100644 index 0000000..1d3094d --- /dev/null +++ b/src/SharpMud.Engine/Help/HelpTopicId.cs @@ -0,0 +1,11 @@ +namespace SharpMud.Engine.Help; + +/// Stable identity for a - same wrapper-struct shape as ThingId. +public readonly record struct HelpTopicId(Guid Value) +{ + /// Generates a new, time-ordered id. + public static HelpTopicId New() => new(Guid.CreateVersion7()); + + /// + public override string ToString() => Value.ToString(); +} diff --git a/src/SharpMud.Engine/Help/IEmbeddingProvider.cs b/src/SharpMud.Engine/Help/IEmbeddingProvider.cs new file mode 100644 index 0000000..9ad47ec --- /dev/null +++ b/src/SharpMud.Engine/Help/IEmbeddingProvider.cs @@ -0,0 +1,32 @@ +namespace SharpMud.Engine.Help; + +/// +/// Turns text into an embedding vector - the abstraction ADR-0010 puts +/// between the help-search pipeline and whatever produces embeddings, so a +/// real model can be swapped in later behind this interface without +/// touching or any command. The default +/// implementation () is a deterministic +/// placeholder, not a production-quality semantic model. +/// +public interface IEmbeddingProvider +{ + /// Identifies which model/version produced an embedding - stored on so a future model change can be detected, not just a content change. + string ModelId { get; } + + /// + /// Below this cosine score, treats a + /// match as too weak to show - "no help topic found" beats an + /// unrelated guess (ADR-0010). Owned by the provider, not the search + /// index, because different embedding models produce cosine scores on + /// very different scales - e.g. a sparse hashed-bag-of-words vector + /// scores near 0 for unrelated text, while a dense sentence-embedding + /// model can score 0.3+ for completely unrelated text and 0.6+ for a + /// real match (measured empirically for + /// SmartComponents.LocalEmbeddings' default model - see + /// ADR-0011); one hardcoded threshold can't be right for both. + /// + double RelevanceThreshold { get; } + + /// Embeds into a fixed-dimension vector. + Task EmbedAsync(string text, CancellationToken ct); +} diff --git a/src/SharpMud.Engine/Help/IHelpRepository.cs b/src/SharpMud.Engine/Help/IHelpRepository.cs new file mode 100644 index 0000000..af14654 --- /dev/null +++ b/src/SharpMud.Engine/Help/IHelpRepository.cs @@ -0,0 +1,25 @@ +namespace SharpMud.Engine.Help; + +/// +/// The aggregate-root repository for (ADR-0010) - +/// one repository per independently-addressable aggregate root, matching +/// IThingRepository's shape (see docs/persistence.md: no +/// generic/per-concept repositories). +/// +public interface IHelpRepository +{ + /// Finds a topic by exact, case-insensitive or match - tier one of ADR-0010's lookup pipeline. + Task FindByNameOrAliasAsync(string name, CancellationToken ct); + + /// Finds every topic whose contains an exact, case-insensitive match - tier two of ADR-0010's lookup pipeline. + Task> FindByKeywordAsync(string keyword, CancellationToken ct); + + /// Every topic, each with its current loaded - used by (tier three) and by helpindex rebuild. + Task> GetAllTopicsAsync(CancellationToken ct); + + /// Creates or overwrites , including its current , as one unit. + Task SaveTopicAsync(HelpTopic topic, CancellationToken ct); + + /// Deletes a topic and its chunks. No-op if doesn't exist. + Task DeleteTopicAsync(HelpTopicId id, CancellationToken ct); +} diff --git a/src/SharpMud.Engine/Help/IHelpSearchIndex.cs b/src/SharpMud.Engine/Help/IHelpSearchIndex.cs new file mode 100644 index 0000000..ef3f383 --- /dev/null +++ b/src/SharpMud.Engine/Help/IHelpSearchIndex.cs @@ -0,0 +1,18 @@ +namespace SharpMud.Engine.Help; + +/// +/// Semantic search over content (ADR-0010) - tier +/// three of the help lookup pipeline, only ever consulted after exact-name/ +/// alias and keyword lookup both miss. Implementations apply their own +/// relevance threshold and must return an empty list rather than a weak +/// guess when nothing clears it - "no help topic found" beats an unrelated +/// answer. The default implementation () +/// is storage-agnostic (reads through only), +/// so a future vector-storage swap (e.g. an ANN-backed index) replaces this +/// interface's implementation without touching any command. +/// +public interface IHelpSearchIndex +{ + /// Returns the best-matching topics for , ordered by descending relevance, above whatever threshold this implementation enforces. Empty, not an exception or a weak guess, when nothing qualifies. + Task> SearchAsync(string query, CancellationToken ct); +} diff --git a/src/SharpMud.Engine/Help/StubEmbeddingProvider.cs b/src/SharpMud.Engine/Help/StubEmbeddingProvider.cs new file mode 100644 index 0000000..ec7d30a --- /dev/null +++ b/src/SharpMud.Engine/Help/StubEmbeddingProvider.cs @@ -0,0 +1,63 @@ +namespace SharpMud.Engine.Help; + +/// +/// Deterministic placeholder (ADR-0010) - +/// feature-hashes lowercase word tokens into a fixed-size vector (the +/// "hashing trick"), with no external dependency or model asset. Retrieval +/// quality reflects literal word overlap only (no synonym/semantic +/// understanding - "wizard" and "mage" share no signal); this exists to +/// validate the pipeline/abstraction end-to-end with fully deterministic, +/// testable output, not to be the long-term provider. A real model (local +/// or API-based) swaps in behind later - +/// see ADR-0010's Negative Consequences. +/// +public sealed class StubEmbeddingProvider : IEmbeddingProvider +{ + private const int Dimensions = 128; + + private static readonly char[] TokenSeparators = [' ', '\t', '\n', '\r', '.', ',', '!', '?', ';', ':', '"', '\'']; + + /// + public string ModelId => "stub-hashed-bow-v1"; + + /// Tuned empirically for this provider's sparse hashed-bag-of-words vectors, which score near 0 for genuinely unrelated text. + public double RelevanceThreshold => 0.15; + + /// + public Task EmbedAsync(string text, CancellationToken ct) + { + var vector = new float[Dimensions]; + foreach (var word in text.ToLowerInvariant().Split(TokenSeparators, StringSplitOptions.RemoveEmptyEntries)) + vector[(int)(Fnv1aHash(word) % Dimensions)] += 1f; + + Normalize(vector); + return Task.FromResult(vector); + } + + // FNV-1a - deterministic across processes/runs, unlike string.GetHashCode + // (randomized per-process by default in .NET), which this needs to be + // reproducible: the same word must always hash to the same bucket, both + // within one run (query vs. chunk) and across separate rebuild/search + // calls. + private static uint Fnv1aHash(string text) + { + var hash = 2166136261u; + foreach (var ch in text) + { + hash ^= ch; + hash *= 16777619u; + } + + return hash; + } + + private static void Normalize(float[] vector) + { + var magnitude = MathF.Sqrt(vector.Sum(v => v * v)); + if (magnitude == 0f) + return; + + for (var i = 0; i < vector.Length; i++) + vector[i] /= magnitude; + } +} diff --git a/src/SharpMud.Hosting/ServiceCollectionExtensions.cs b/src/SharpMud.Hosting/ServiceCollectionExtensions.cs index f6ceee9..26a2aff 100644 --- a/src/SharpMud.Hosting/ServiceCollectionExtensions.cs +++ b/src/SharpMud.Hosting/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using SharpMud.Engine.Commands; using SharpMud.Engine.Commands.Builtin; using SharpMud.Engine.Core; +using SharpMud.Engine.Help; using SharpMud.Engine.Sessions; using SharpMud.Engine.Ticking; @@ -27,7 +28,7 @@ public static IServiceCollection AddSharpMudRuleset(this IServiceCollection serv services.AddSingleton(sp => { var registry = new CommandRegistry(); - BuiltinCommands.RegisterAll(registry); + BuiltinCommands.RegisterAll(registry, sp.GetRequiredService(), sp.GetRequiredService()); registerRuleset(sp, registry); return registry; }); diff --git a/src/SharpMud.Persistence.Sqlite/ServiceCollectionExtensions.cs b/src/SharpMud.Persistence.Sqlite/ServiceCollectionExtensions.cs index 75809c9..f8a0668 100644 --- a/src/SharpMud.Persistence.Sqlite/ServiceCollectionExtensions.cs +++ b/src/SharpMud.Persistence.Sqlite/ServiceCollectionExtensions.cs @@ -1,19 +1,32 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SharpMud.Engine.Core; +using SharpMud.Engine.Help; using SharpMud.Persistence; namespace SharpMud.Persistence.Sqlite; public static class ServiceCollectionExtensions { - /// Registers backed by SQLite at . + /// + /// Registers and + /// (ADR-0010), both backed by SQLite at , plus + /// the default help-search stack (/ + /// ) - registered here, not left + /// opt-in, since help (unlike helptopic/helpindex) + /// is always part of , + /// the same "always-available infrastructure" shape already has here. + /// public static IServiceCollection AddSharpMudSqlitePersistence(this IServiceCollection services, string dbPath) { ArgumentNullException.ThrowIfNull(dbPath); services.AddDbContextFactory(options => options.UseSqlite($"Data Source={dbPath}")); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); return services; diff --git a/src/SharpMud.Persistence/Configurations/HelpTopicChunkConfiguration.cs b/src/SharpMud.Persistence/Configurations/HelpTopicChunkConfiguration.cs new file mode 100644 index 0000000..a2029e8 --- /dev/null +++ b/src/SharpMud.Persistence/Configurations/HelpTopicChunkConfiguration.cs @@ -0,0 +1,53 @@ +using System.Runtime.InteropServices; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SharpMud.Engine.Help; + +namespace SharpMud.Persistence.Configurations; + +// HelpTopicChunk is a record (constructor-bound materialization, same +// mechanism ThingId already relies on for Thing.Id) - its own table with an +// explicit HelpTopicId FK, not an EF navigation collection off HelpTopic. +// HelpRepository loads/saves it manually, mirroring how ThingRepository +// handles the Behaviors table. +public sealed class HelpTopicChunkConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("HelpTopicChunks"); + builder.HasKey(x => x.Id); + builder.Property(x => x.HelpTopicId).HasConversion(id => id.Value, value => new HelpTopicId(value)); + builder.HasIndex(x => x.HelpTopicId); + + // A real FK constraint (no navigation property needed on either + // side) - HelpRepository.SaveTopicAsync/DeleteTopicAsync already + // delete/insert a topic and its chunks in the same + // SaveChangesAsync batch, so EF's automatic dependency ordering + // doesn't change either path's behavior. This makes the "explicit + // HelpTopicId FK" claim in the comment above actually true at the + // DB level, and cascades a delete so nothing outside + // HelpRepository can orphan a topic's chunks - caught in PR + // review. + builder.HasOne() + .WithMany() + .HasForeignKey(x => x.HelpTopicId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Property(x => x.Text).IsRequired(); + builder.Property(x => x.EmbeddingModelId).IsRequired(); + builder.Property(x => x.SourceContentHash).IsRequired(); + + // Stored as a BLOB (ADR-0010) rather than a delimited string - a + // straight little-endian byte reinterpretation of the float[], not + // meant to be portable across machine architectures, only read back + // by this same process/DB. Factored into static methods (not inline + // Span usage) because a ref struct value can't appear inside the + // expression tree HasConversion compiles its lambdas into (CS8640). + builder.Property(x => x.Embedding) + .HasConversion(v => Serialize(v), v => Deserialize(v)); + } + + private static byte[] Serialize(float[] vector) => MemoryMarshal.AsBytes(vector.AsSpan()).ToArray(); + + private static float[] Deserialize(byte[] bytes) => MemoryMarshal.Cast(bytes).ToArray(); +} diff --git a/src/SharpMud.Persistence/Configurations/HelpTopicConfiguration.cs b/src/SharpMud.Persistence/Configurations/HelpTopicConfiguration.cs new file mode 100644 index 0000000..def5da7 --- /dev/null +++ b/src/SharpMud.Persistence/Configurations/HelpTopicConfiguration.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SharpMud.Engine.Help; + +namespace SharpMud.Persistence.Configurations; + +// Aliases/Keywords are List-backed IReadOnlyList properties +// with no public setter (see HelpTopic) - EF binds directly to the backing +// field (PropertyAccessMode.Field) instead of requiring a settable property, +// same "expose IReadOnlyList, mutate via a named method" shape +// coding-standards.md requires for collections on domain entities. +public sealed class HelpTopicConfiguration : IEntityTypeConfiguration +{ + // ASCII "unit separator" (0x1F) - won't collide with real alias/keyword text. + private const char Separator = '\u001F'; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("HelpTopics"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).HasConversion(id => id.Value, value => new HelpTopicId(value)); + // NOCASE, not SQLite's default BINARY collation - HelpRepository's + // app-level lookups (FindByNameOrAliasAsync) already match Key + // case-insensitively, so the unique index below has to enforce + // uniqueness under that same semantics or it doesn't actually + // guard anything: without this, "wizard" and "Wizard" pass as two + // distinct rows at the DB level even though the app treats them as + // the same topic - caught in PR review (the index alone, added in + // the previous round, didn't close the race it was meant to). + builder.Property(x => x.Key).IsRequired().UseCollation("NOCASE"); + + // Guards the check-then-act gap in HelpTopicEditCommand + // (FindByNameOrAliasAsync -> create-if-null -> SaveTopicAsync, no + // transaction spanning it) - without this, two concurrent + // `helptopic newtopic ...` calls for the same new key could both + // observe null and each insert a row, leaving FindByNameOrAliasAsync's + // FirstOrDefault to pick one arbitrarily. Low-probability today + // (solo/small-collaborator project), but cheap - caught in PR + // review. + builder.HasIndex(x => x.Key).IsUnique(); + builder.Property(x => x.Category).IsRequired(); + builder.Property(x => x.Body).IsRequired(); + builder.Property(x => x.UpdatedAtUtc).IsRequired(); + + builder.Property(x => x.Aliases) + .HasConversion(v => Join(v), v => Split(v)) + .Metadata.SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.Property(x => x.Keywords) + .HasConversion(v => Join(v), v => Split(v)) + .Metadata.SetPropertyAccessMode(PropertyAccessMode.Field); + + // HelpTopicChunk is its own table with an explicit HelpTopicId FK, + // loaded/saved manually by HelpRepository (mirrors ThingRepository's + // Behaviors handling) rather than an EF navigation collection. + builder.Ignore(x => x.Chunks); + builder.Ignore(x => x.ContentHash); // derived from Body, not stored + } + + // Factored out of the HasConversion lambdas above - a collection + // expression ([]) can't appear inside an expression tree (CS9175), + // which is what EF compiles a HasConversion lambda into. + private static string Join(IReadOnlyList values) => string.Join(Separator, values); + + private static List Split(string value) => + value.Length == 0 ? new List() : value.Split(Separator, StringSplitOptions.RemoveEmptyEntries).ToList(); +} diff --git a/src/SharpMud.Persistence/GameDbContext.cs b/src/SharpMud.Persistence/GameDbContext.cs index 923858a..37c197d 100644 --- a/src/SharpMud.Persistence/GameDbContext.cs +++ b/src/SharpMud.Persistence/GameDbContext.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using SharpMud.Engine.Core; +using SharpMud.Engine.Help; namespace SharpMud.Persistence; @@ -16,6 +17,13 @@ public sealed class GameDbContext( public DbSet Things => Set(); public DbSet Behaviors => Set(); + // HelpTopic/HelpTopicChunk (ADR-0010) - unlike Thing/Behavior, these have + // no Rehydration/event-firing concerns, so they use EF Core normally + // (real conversions, no shadow FKs, no manual reconstruction) rather than + // ThingRepository's hand-rolled pattern. See HelpRepository. + public DbSet HelpTopics => Set(); + public DbSet HelpTopicChunks => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { // Picks up every IEntityTypeConfiguration in this assembly - diff --git a/src/SharpMud.Persistence/HelpRepository.cs b/src/SharpMud.Persistence/HelpRepository.cs new file mode 100644 index 0000000..84bba2a --- /dev/null +++ b/src/SharpMud.Persistence/HelpRepository.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; +using SharpMud.Engine.Help; + +namespace SharpMud.Persistence; + +// Loads the full HelpTopic/HelpTopicChunk corpus per call, same "load +// everything" shape ThingRepository already uses - fine at help-topic scale +// (dozens-hundreds of topics), see ADR-0010's Negative Consequences. Fresh +// GameDbContext per call (IDbContextFactory), same reason as +// ThingRepository: DbContext isn't thread-safe, and re-adding the same +// tracked instances across repeated saves would throw. +public sealed class HelpRepository(IDbContextFactory dbContextFactory) : IHelpRepository +{ + public async Task FindByNameOrAliasAsync(string name, CancellationToken ct) + { + var topics = await GetAllTopicsAsync(ct); + return topics.FirstOrDefault(t => + string.Equals(t.Key, name, StringComparison.OrdinalIgnoreCase) || + t.Aliases.Any(a => string.Equals(a, name, StringComparison.OrdinalIgnoreCase))); + } + + public async Task> FindByKeywordAsync(string keyword, CancellationToken ct) + { + var topics = await GetAllTopicsAsync(ct); + return topics + .Where(t => t.Keywords.Any(k => string.Equals(k, keyword, StringComparison.OrdinalIgnoreCase))) + // Deterministic order (not insertion/rowid order, which SQL + // makes no guarantee about) so a caller taking FirstOrDefault + // when multiple topics share a keyword gets a consistent + // result across calls, not an arbitrary one - caught in PR + // review. + .OrderBy(t => t.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public async Task> GetAllTopicsAsync(CancellationToken ct) + { + await using var context = await dbContextFactory.CreateDbContextAsync(ct); + + var topics = await context.HelpTopics.AsNoTracking().ToListAsync(ct); + var chunksByTopic = (await context.HelpTopicChunks.AsNoTracking().ToListAsync(ct)) + .ToLookup(c => c.HelpTopicId); + + foreach (var topic in topics) + topic.ReplaceChunks(chunksByTopic[topic.Id].OrderBy(c => c.ChunkIndex)); + + return topics; + } + + // Delete-then-insert, two SaveChangesAsync calls - same PK-conflict + // avoidance as ThingRepository.SaveTreeAsync (re-adding a still-tracked + // instance in one batch throws). + public async Task SaveTopicAsync(HelpTopic topic, CancellationToken ct) + { + await using var context = await dbContextFactory.CreateDbContextAsync(ct); + + var existingTopic = await context.HelpTopics.Where(t => t.Id == topic.Id).ToListAsync(ct); + context.HelpTopics.RemoveRange(existingTopic); + + var existingChunks = await context.HelpTopicChunks.Where(c => c.HelpTopicId == topic.Id).ToListAsync(ct); + context.HelpTopicChunks.RemoveRange(existingChunks); + + await context.SaveChangesAsync(ct); + + context.HelpTopics.Add(topic); + context.HelpTopicChunks.AddRange(topic.Chunks); + + await context.SaveChangesAsync(ct); + } + + public async Task DeleteTopicAsync(HelpTopicId id, CancellationToken ct) + { + await using var context = await dbContextFactory.CreateDbContextAsync(ct); + + var chunks = await context.HelpTopicChunks.Where(c => c.HelpTopicId == id).ToListAsync(ct); + context.HelpTopicChunks.RemoveRange(chunks); + + var topics = await context.HelpTopics.Where(t => t.Id == id).ToListAsync(ct); + context.HelpTopics.RemoveRange(topics); + + await context.SaveChangesAsync(ct); + } +} diff --git a/tests/SharpMud.Engine.Tests/Commands/Builtin/Admin/HelpIndexRebuildCommandTests.cs b/tests/SharpMud.Engine.Tests/Commands/Builtin/Admin/HelpIndexRebuildCommandTests.cs new file mode 100644 index 0000000..0eceb8f --- /dev/null +++ b/tests/SharpMud.Engine.Tests/Commands/Builtin/Admin/HelpIndexRebuildCommandTests.cs @@ -0,0 +1,57 @@ +using SharpMud.Engine.Commands; +using SharpMud.Engine.Commands.Builtin.Admin; +using SharpMud.Engine.Core; +using SharpMud.Engine.Help; +using SharpMud.Engine.Sessions; + +namespace SharpMud.Engine.Tests.Commands.Builtin.Admin; + +public sealed class HelpIndexRebuildCommandTests +{ + private static (Thing Actor, World World, ISession Session) MakeActor() + { + var world = new World(); + var actor = new Thing { Id = ThingId.New(), Name = "Builder" }; + world.Register(actor); + return (actor, world, Substitute.For()); + } + + [Fact] + public async Task ExecuteAsync_ReplacesChunks_ForEveryTopic() + { + var (actor, world, session) = MakeActor(); + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "How to become a wizard." }; + var repository = Substitute.For(); + repository.GetAllTopicsAsync(Arg.Any()).Returns([topic]); + var embeddingProvider = Substitute.For(); + embeddingProvider.ModelId.Returns("stub-hashed-bow-v1"); + embeddingProvider.EmbedAsync(Arg.Any(), Arg.Any()).Returns([1f, 0f]); + + var sut = new HelpIndexRebuildCommand(repository, embeddingProvider); + var ctx = new CommandContext(actor, actor, ["rebuild"], world, session); + + await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken); + + topic.Chunks.Should().ContainSingle(); + topic.Chunks[0].SourceContentHash.Should().Be(topic.ContentHash); + topic.Chunks[0].EmbeddingModelId.Should().Be("stub-hashed-bow-v1"); + await repository.Received(1).SaveTopicAsync(topic, Arg.Any()); + await session.Received(1).WriteLineAsync("Rebuilt the help index: 1 topic(s), 1 chunk(s).", Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_SendsUsageMessage_WhenArgumentIsNotRebuild() + { + var (actor, world, session) = MakeActor(); + var repository = Substitute.For(); + var embeddingProvider = Substitute.For(); + + var sut = new HelpIndexRebuildCommand(repository, embeddingProvider); + var ctx = new CommandContext(actor, actor, ["nonsense"], world, session); + + await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken); + + await session.Received(1).WriteLineAsync("Usage: helpindex rebuild", Arg.Any()); + await repository.DidNotReceiveWithAnyArgs().GetAllTopicsAsync(Arg.Any()); + } +} diff --git a/tests/SharpMud.Engine.Tests/Commands/Builtin/Admin/HelpTopicEditCommandTests.cs b/tests/SharpMud.Engine.Tests/Commands/Builtin/Admin/HelpTopicEditCommandTests.cs new file mode 100644 index 0000000..7e73950 --- /dev/null +++ b/tests/SharpMud.Engine.Tests/Commands/Builtin/Admin/HelpTopicEditCommandTests.cs @@ -0,0 +1,90 @@ +using SharpMud.Engine.Commands; +using SharpMud.Engine.Commands.Builtin.Admin; +using SharpMud.Engine.Core; +using SharpMud.Engine.Help; +using SharpMud.Engine.Sessions; + +namespace SharpMud.Engine.Tests.Commands.Builtin.Admin; + +public sealed class HelpTopicEditCommandTests +{ + private static (Thing Actor, World World, ISession Session) MakeActor() + { + var world = new World(); + var actor = new Thing { Id = ThingId.New(), Name = "Builder" }; + world.Register(actor); + return (actor, world, Substitute.For()); + } + + [Fact] + public async Task ExecuteAsync_CreatesNewTopic_WhenNoneExists() + { + var (actor, world, session) = MakeActor(); + var repository = Substitute.For(); + repository.FindByNameOrAliasAsync("wizard", Arg.Any()).Returns((HelpTopic?)null); + + var sut = new HelpTopicEditCommand(repository); + var ctx = new CommandContext(actor, actor, ["wizard", "How", "to", "become", "a", "wizard."], world, session); + + await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken); + + await repository.Received(1).SaveTopicAsync( + Arg.Is(t => t.Key == "wizard" && t.Body == "How to become a wizard."), + Arg.Any()); + await session.Received(1).WriteLineAsync("Help topic 'wizard' saved.", Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_OverwritesBody_WhenTopicAlreadyExists() + { + var (actor, world, session) = MakeActor(); + var existing = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "Old text." }; + var repository = Substitute.For(); + repository.FindByNameOrAliasAsync("wizard", Arg.Any()).Returns(existing); + + var sut = new HelpTopicEditCommand(repository); + var ctx = new CommandContext(actor, actor, ["wizard", "New", "text."], world, session); + + await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken); + + await repository.Received(1).SaveTopicAsync( + Arg.Is(t => t.Id == existing.Id && t.Body == "New text."), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_DoesNotReassignKey_WhenExistingTopicFoundByDifferentCasing() + { + var (actor, world, session) = MakeActor(); + var existing = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "Old text." }; + var repository = Substitute.For(); + // FindByNameOrAliasAsync is case-insensitive - "Wizard" finds the + // "wizard" topic, same as it would for an alias once those are + // settable. + repository.FindByNameOrAliasAsync("Wizard", Arg.Any()).Returns(existing); + + var sut = new HelpTopicEditCommand(repository); + var ctx = new CommandContext(actor, actor, ["Wizard", "New", "text."], world, session); + + await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken); + + await repository.Received(1).SaveTopicAsync( + Arg.Is(t => t.Key == "wizard" && t.Body == "New text."), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_SendsUsageMessage_WhenMissingBody() + { + var (actor, world, session) = MakeActor(); + var repository = Substitute.For(); + + var sut = new HelpTopicEditCommand(repository); + var ctx = new CommandContext(actor, actor, ["wizard"], world, session); + + await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken); + + await session.Received(1).WriteLineAsync("Usage: helptopic ", Arg.Any()); + await repository.DidNotReceiveWithAnyArgs().SaveTopicAsync(default!, Arg.Any()); + } +} diff --git a/tests/SharpMud.Engine.Tests/Commands/HelpCommandTests.cs b/tests/SharpMud.Engine.Tests/Commands/HelpCommandTests.cs index 12d3a4c..1154253 100644 --- a/tests/SharpMud.Engine.Tests/Commands/HelpCommandTests.cs +++ b/tests/SharpMud.Engine.Tests/Commands/HelpCommandTests.cs @@ -2,6 +2,7 @@ using SharpMud.Engine.Commands; using SharpMud.Engine.Commands.Builtin; using SharpMud.Engine.Core; +using SharpMud.Engine.Help; using SharpMud.Engine.Sessions; namespace SharpMud.Engine.Tests.Commands; @@ -24,6 +25,17 @@ private static Thing MakeActor(SecurityRole roles) return actor; } + private static HelpCommand MakeSut( + ICommandRegistry? registry = null, + IHelpRepository? helpRepository = null, + IHelpSearchIndex? helpSearchIndex = null) + { + return new HelpCommand( + registry ?? new CommandRegistry(), + helpRepository ?? Substitute.For(), + helpSearchIndex ?? Substitute.For()); + } + [Fact] public async Task ExecuteAsync_OmitsRoleGatedCommand_WhenActorLacksTheRequiredRole() { @@ -33,7 +45,7 @@ public async Task ExecuteAsync_OmitsRoleGatedCommand_WhenActorLacksTheRequiredRo registry.RegisterWithRole(new FakeCommand("ban"), SecurityRole.FullAdmin); var actor = MakeActor(SecurityRole.Player); - var sut = new HelpCommand(registry); + var sut = MakeSut(registry); var ctx = new CommandContext(actor, actor, [], new World(), session); await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken); @@ -50,7 +62,7 @@ public async Task ExecuteAsync_IncludesRoleGatedCommand_WhenActorHasTheRequiredR registry.RegisterWithRole(new FakeCommand("ban"), SecurityRole.FullAdmin); var actor = MakeActor(SecurityRole.FullAdmin); - var sut = new HelpCommand(registry); + var sut = MakeSut(registry); var ctx = new CommandContext(actor, actor, [], new World(), session); await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken); @@ -66,11 +78,114 @@ public async Task ExecuteAsync_AlwaysIncludesNonGatedCommands_RegardlessOfRole() registry.RegisterOpen(new FakeCommand("look")); var actor = MakeActor(SecurityRole.Player); - var sut = new HelpCommand(registry); + var sut = MakeSut(registry); var ctx = new CommandContext(actor, actor, [], new World(), session); await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken); await session.Received(1).WriteLineAsync(Arg.Is(s => s!.Contains("look")), Arg.Any()); } + + [Fact] + public async Task ExecuteAsync_WritesTopicBody_OnExactKeyMatch() + { + var session = Substitute.For(); + var actor = MakeActor(SecurityRole.Player); + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "How to become a wizard." }; + var helpRepository = Substitute.For(); + helpRepository.FindByNameOrAliasAsync("wizard", Arg.Any()).Returns(topic); + + var sut = MakeSut(helpRepository: helpRepository); + var ctx = new CommandContext(actor, actor, ["wizard"], new World(), session); + + await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken); + + await session.Received(1).WriteLineAsync("How to become a wizard.", Arg.Any()); + await helpRepository.DidNotReceiveWithAnyArgs().FindByKeywordAsync(default!, default); + } + + [Fact] + public async Task ExecuteAsync_FallsBackToKeywordMatch_WhenExactMatchMisses() + { + var session = Substitute.For(); + var actor = MakeActor(SecurityRole.Player); + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "How to become a wizard." }; + var helpRepository = Substitute.For(); + helpRepository.FindByNameOrAliasAsync("magic", Arg.Any()).Returns((HelpTopic?)null); + helpRepository.FindByKeywordAsync("magic", Arg.Any()).Returns([topic]); + var helpSearchIndex = Substitute.For(); + + var sut = MakeSut(helpRepository: helpRepository, helpSearchIndex: helpSearchIndex); + var ctx = new CommandContext(actor, actor, ["magic"], new World(), session); + + await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken); + + await session.Received(1).WriteLineAsync("How to become a wizard.", Arg.Any()); + await helpSearchIndex.DidNotReceiveWithAnyArgs().SearchAsync(default!, default); + } + + [Fact] + public async Task ExecuteAsync_ChecksEachQueryWordAgainstKeywords_ForMultiWordQueries() + { + var session = Substitute.For(); + var actor = MakeActor(SecurityRole.Player); + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "How to become a wizard." }; + var helpRepository = Substitute.For(); + var query = "teach me magic"; + helpRepository.FindByNameOrAliasAsync(query, Arg.Any()).Returns((HelpTopic?)null); + // FindByKeywordAsync's contract is an exact match against one + // Keywords entry - the query as a whole ("teach me magic") never + // matches, only the individual token "magic" does. + helpRepository.FindByKeywordAsync("teach", Arg.Any()).Returns([]); + helpRepository.FindByKeywordAsync("me", Arg.Any()).Returns([]); + helpRepository.FindByKeywordAsync("magic", Arg.Any()).Returns([topic]); + var helpSearchIndex = Substitute.For(); + + var sut = MakeSut(helpRepository: helpRepository, helpSearchIndex: helpSearchIndex); + var ctx = new CommandContext(actor, actor, ["teach", "me", "magic"], new World(), session); + + await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken); + + await session.Received(1).WriteLineAsync("How to become a wizard.", Arg.Any()); + await helpSearchIndex.DidNotReceiveWithAnyArgs().SearchAsync(default!, default); + } + + [Fact] + public async Task ExecuteAsync_FallsBackToSemanticSearch_WhenExactAndKeywordMatchesMiss() + { + var session = Substitute.For(); + var actor = MakeActor(SecurityRole.Player); + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "How to become a wizard." }; + var helpRepository = Substitute.For(); + helpRepository.FindByNameOrAliasAsync("how do i become a wizard", Arg.Any()).Returns((HelpTopic?)null); + helpRepository.FindByKeywordAsync("how do i become a wizard", Arg.Any()).Returns([]); + var helpSearchIndex = Substitute.For(); + helpSearchIndex.SearchAsync("how do i become a wizard", Arg.Any()).Returns([new HelpSearchHit(topic, 0.4)]); + + var sut = MakeSut(helpRepository: helpRepository, helpSearchIndex: helpSearchIndex); + var ctx = new CommandContext(actor, actor, ["how", "do", "i", "become", "a", "wizard"], new World(), session); + + await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken); + + await session.Received(1).WriteLineAsync("How to become a wizard.", Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_ReportsNoTopicFound_WhenAllThreeTiersMiss() + { + var session = Substitute.For(); + var actor = MakeActor(SecurityRole.Player); + var helpRepository = Substitute.For(); + helpRepository.FindByNameOrAliasAsync("nonsense", Arg.Any()).Returns((HelpTopic?)null); + helpRepository.FindByKeywordAsync("nonsense", Arg.Any()).Returns([]); + var helpSearchIndex = Substitute.For(); + helpSearchIndex.SearchAsync("nonsense", Arg.Any()).Returns([]); + + var sut = MakeSut(helpRepository: helpRepository, helpSearchIndex: helpSearchIndex); + var ctx = new CommandContext(actor, actor, ["nonsense"], new World(), session); + + await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken); + + await session.Received(1).WriteLineAsync("No help topic found for 'nonsense'.", Arg.Any()); + } } diff --git a/tests/SharpMud.Engine.Tests/Help/CosineHelpSearchIndexTests.cs b/tests/SharpMud.Engine.Tests/Help/CosineHelpSearchIndexTests.cs new file mode 100644 index 0000000..6a71d9a --- /dev/null +++ b/tests/SharpMud.Engine.Tests/Help/CosineHelpSearchIndexTests.cs @@ -0,0 +1,89 @@ +using SharpMud.Engine.Help; + +namespace SharpMud.Engine.Tests.Help; + +public sealed class CosineHelpSearchIndexTests +{ + private static HelpTopic MakeTopic(string key, params HelpTopicChunk[] chunks) + { + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = key }; + topic.ReplaceChunks(chunks); + return topic; + } + + [Fact] + public async Task SearchAsync_ReturnsTopic_WhenChunkScoreClearsThreshold() + { + var topic = MakeTopic("wizard", new HelpTopicChunk(Guid.NewGuid(), HelpTopicId.New(), 0, "text", [1f, 0f], "model", "hash")); + var repository = Substitute.For(); + repository.GetAllTopicsAsync(Arg.Any()).Returns([topic]); + var embeddingProvider = Substitute.For(); + embeddingProvider.RelevanceThreshold.Returns(0.15); + embeddingProvider.EmbedAsync("query", Arg.Any()).Returns([1f, 0f]); + + var sut = new CosineHelpSearchIndex(repository, embeddingProvider); + + var hits = await sut.SearchAsync("query", TestContext.Current.CancellationToken); + + hits.Should().ContainSingle(); + hits[0].Topic.Should().Be(topic); + hits[0].Score.Should().BeApproximately(1.0, 0.0001); + } + + [Fact] + public async Task SearchAsync_ReturnsEmpty_WhenBestScoreIsBelowThreshold() + { + var topic = MakeTopic("wizard", new HelpTopicChunk(Guid.NewGuid(), HelpTopicId.New(), 0, "text", [1f, 0f], "model", "hash")); + var repository = Substitute.For(); + repository.GetAllTopicsAsync(Arg.Any()).Returns([topic]); + var embeddingProvider = Substitute.For(); + embeddingProvider.RelevanceThreshold.Returns(0.15); + // Orthogonal vector - cosine similarity 0, well below the threshold. + embeddingProvider.EmbedAsync("query", Arg.Any()).Returns([0f, 1f]); + + var sut = new CosineHelpSearchIndex(repository, embeddingProvider); + + var hits = await sut.SearchAsync("query", TestContext.Current.CancellationToken); + + hits.Should().BeEmpty("a weak match must yield no result, not a wrong-but-confident guess"); + } + + [Fact] + public async Task SearchAsync_OrdersHits_ByDescendingScore() + { + var weakTopic = MakeTopic("weak", new HelpTopicChunk(Guid.NewGuid(), HelpTopicId.New(), 0, "text", [0.2f, 0.98f], "model", "hash")); + var strongTopic = MakeTopic("strong", new HelpTopicChunk(Guid.NewGuid(), HelpTopicId.New(), 0, "text", [1f, 0f], "model", "hash")); + var repository = Substitute.For(); + repository.GetAllTopicsAsync(Arg.Any()).Returns([weakTopic, strongTopic]); + var embeddingProvider = Substitute.For(); + embeddingProvider.RelevanceThreshold.Returns(0.15); + embeddingProvider.EmbedAsync("query", Arg.Any()).Returns([1f, 0f]); + + var sut = new CosineHelpSearchIndex(repository, embeddingProvider); + + var hits = await sut.SearchAsync("query", TestContext.Current.CancellationToken); + + hits.Should().HaveCount(2); + hits[0].Topic.Should().Be(strongTopic); + hits[1].Topic.Should().Be(weakTopic); + } + + [Fact] + public async Task SearchAsync_UsesBestChunkPerTopic_NotFirst() + { + var weakChunk = new HelpTopicChunk(Guid.NewGuid(), HelpTopicId.New(), 0, "weak", [0f, 1f], "model", "hash"); + var strongChunk = new HelpTopicChunk(Guid.NewGuid(), HelpTopicId.New(), 1, "strong", [1f, 0f], "model", "hash"); + var topic = MakeTopic("wizard", weakChunk, strongChunk); + var repository = Substitute.For(); + repository.GetAllTopicsAsync(Arg.Any()).Returns([topic]); + var embeddingProvider = Substitute.For(); + embeddingProvider.RelevanceThreshold.Returns(0.15); + embeddingProvider.EmbedAsync("query", Arg.Any()).Returns([1f, 0f]); + + var sut = new CosineHelpSearchIndex(repository, embeddingProvider); + + var hits = await sut.SearchAsync("query", TestContext.Current.CancellationToken); + + hits.Should().ContainSingle().Which.Score.Should().BeApproximately(1.0, 0.0001); + } +} diff --git a/tests/SharpMud.Engine.Tests/Help/HelpTopicChunkerTests.cs b/tests/SharpMud.Engine.Tests/Help/HelpTopicChunkerTests.cs new file mode 100644 index 0000000..f6f3a65 --- /dev/null +++ b/tests/SharpMud.Engine.Tests/Help/HelpTopicChunkerTests.cs @@ -0,0 +1,29 @@ +using SharpMud.Engine.Help; + +namespace SharpMud.Engine.Tests.Help; + +public sealed class HelpTopicChunkerTests +{ + [Fact] + public void Split_ReturnsEmpty_ForEmptyOrWhitespaceBody() + { + HelpTopicChunker.Split("").Should().BeEmpty(); + HelpTopicChunker.Split(" \n ").Should().BeEmpty(); + } + + [Fact] + public void Split_ReturnsSingleChunk_ForSingleParagraph() + { + var chunks = HelpTopicChunker.Split("Wizards cast spells using mana."); + + chunks.Should().ContainSingle().Which.Should().Be("Wizards cast spells using mana."); + } + + [Fact] + public void Split_SplitsOnBlankLines_IntoTrimmedParagraphs() + { + var chunks = HelpTopicChunker.Split("First paragraph.\n\nSecond paragraph.\n\n Third, with padding. "); + + chunks.Should().Equal("First paragraph.", "Second paragraph.", "Third, with padding."); + } +} diff --git a/tests/SharpMud.Engine.Tests/Help/HelpTopicTests.cs b/tests/SharpMud.Engine.Tests/Help/HelpTopicTests.cs new file mode 100644 index 0000000..99e40fc --- /dev/null +++ b/tests/SharpMud.Engine.Tests/Help/HelpTopicTests.cs @@ -0,0 +1,60 @@ +using SharpMud.Engine.Help; + +namespace SharpMud.Engine.Tests.Help; + +public sealed class HelpTopicTests +{ + [Fact] + public void ContentHash_Changes_WhenBodyChanges() + { + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "First version." }; + var firstHash = topic.ContentHash; + + topic.Body = "Second version."; + + topic.ContentHash.Should().NotBe(firstHash); + } + + [Fact] + public void ContentHash_IsStable_ForUnchangedBody() + { + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "Stable text." }; + + topic.ContentHash.Should().Be(topic.ContentHash); + } + + [Fact] + public void SetAliases_ReplacesPreviousAliases_Wholesale() + { + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard" }; + topic.SetAliases(["mage", "sorcerer"]); + + topic.SetAliases(["spellcaster"]); + + topic.Aliases.Should().Equal("spellcaster"); + } + + [Fact] + public void ReplaceChunks_ReplacesPreviousChunks_Wholesale() + { + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard" }; + var firstChunk = new HelpTopicChunk(Guid.NewGuid(), topic.Id, 0, "old", [1f], "model-v1", "hash1"); + topic.ReplaceChunks([firstChunk]); + + var secondChunk = new HelpTopicChunk(Guid.NewGuid(), topic.Id, 0, "new", [2f], "model-v1", "hash2"); + topic.ReplaceChunks([secondChunk]); + + topic.Chunks.Should().ContainSingle().Which.Should().Be(secondChunk); + } + + [Fact] + public void Touch_UpdatesUpdatedAtUtc() + { + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard" }; + var before = topic.UpdatedAtUtc; + + topic.Touch(); + + topic.UpdatedAtUtc.Should().BeOnOrAfter(before); + } +} diff --git a/tests/SharpMud.Engine.Tests/Help/StubEmbeddingProviderTests.cs b/tests/SharpMud.Engine.Tests/Help/StubEmbeddingProviderTests.cs new file mode 100644 index 0000000..b7ecbb3 --- /dev/null +++ b/tests/SharpMud.Engine.Tests/Help/StubEmbeddingProviderTests.cs @@ -0,0 +1,52 @@ +using SharpMud.Engine.Help; + +namespace SharpMud.Engine.Tests.Help; + +public sealed class StubEmbeddingProviderTests +{ + private readonly StubEmbeddingProvider _sut = new(); + + [Fact] + public async Task EmbedAsync_ReturnsIdenticalVectors_ForIdenticalText() + { + var first = await _sut.EmbedAsync("how do I become a wizard", TestContext.Current.CancellationToken); + var second = await _sut.EmbedAsync("how do I become a wizard", TestContext.Current.CancellationToken); + + first.Should().BeEquivalentTo(second, "the stub provider must be deterministic across calls"); + } + + [Fact] + public async Task EmbedAsync_ReturnsIdenticalVectors_IgnoringCase() + { + var lower = await _sut.EmbedAsync("wizard magic", TestContext.Current.CancellationToken); + var upper = await _sut.EmbedAsync("WIZARD MAGIC", TestContext.Current.CancellationToken); + + lower.Should().BeEquivalentTo(upper); + } + + [Fact] + public async Task EmbedAsync_ReturnsDifferentVectors_ForDisjointText() + { + var wizard = await _sut.EmbedAsync("wizard", TestContext.Current.CancellationToken); + var shopkeeper = await _sut.EmbedAsync("shopkeeper commerce", TestContext.Current.CancellationToken); + + wizard.Should().NotBeEquivalentTo(shopkeeper); + } + + [Fact] + public async Task EmbedAsync_ReturnsNormalizedVector() + { + var vector = await _sut.EmbedAsync("wizard magic spell", TestContext.Current.CancellationToken); + + var magnitude = Math.Sqrt(vector.Sum(v => (double)v * v)); + magnitude.Should().BeApproximately(1.0, 0.0001); + } + + [Fact] + public async Task EmbedAsync_ReturnsZeroVector_ForEmptyText() + { + var vector = await _sut.EmbedAsync("", TestContext.Current.CancellationToken); + + vector.Should().OnlyContain(v => v == 0f); + } +} diff --git a/tests/SharpMud.Persistence.Tests/HelpRepositoryTests.cs b/tests/SharpMud.Persistence.Tests/HelpRepositoryTests.cs new file mode 100644 index 0000000..67168c7 --- /dev/null +++ b/tests/SharpMud.Persistence.Tests/HelpRepositoryTests.cs @@ -0,0 +1,196 @@ +using Microsoft.EntityFrameworkCore; +using SharpMud.Engine.Help; +using SharpMud.Persistence.Tests.TestKit; + +namespace SharpMud.Persistence.Tests; + +public sealed class HelpRepositoryTests : IDisposable +{ + private readonly TestDbContextFactory _factory = new(); + private readonly HelpRepository _sut; + + public HelpRepositoryTests() + { + _sut = new HelpRepository(_factory); + } + + public void Dispose() => _factory.Dispose(); + + [Fact] + public async Task SaveTopicAsync_ThenGetAllTopicsAsync_RoundTripsTopicWithAliasesKeywordsAndChunks() + { + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Category = "classes", Body = "How to become a wizard." }; + topic.SetAliases(["mage", "sorcerer"]); + topic.SetKeywords(["magic", "spellcasting"]); + topic.ReplaceChunks([ + new HelpTopicChunk(Guid.NewGuid(), topic.Id, 0, "How to become a wizard.", [0.1f, 0.2f, 0.3f], "stub-hashed-bow-v1", topic.ContentHash), + ]); + + await _sut.SaveTopicAsync(topic, TestContext.Current.CancellationToken); + + var all = await _sut.GetAllTopicsAsync(TestContext.Current.CancellationToken); + + var loaded = all.Should().ContainSingle().Subject; + loaded.Key.Should().Be("wizard"); + loaded.Category.Should().Be("classes"); + loaded.Body.Should().Be("How to become a wizard."); + loaded.Aliases.Should().BeEquivalentTo(["mage", "sorcerer"]); + loaded.Keywords.Should().BeEquivalentTo(["magic", "spellcasting"]); + + var chunk = loaded.Chunks.Should().ContainSingle().Subject; + chunk.Text.Should().Be("How to become a wizard."); + chunk.Embedding.Should().BeEquivalentTo(new[] { 0.1f, 0.2f, 0.3f }); + chunk.EmbeddingModelId.Should().Be("stub-hashed-bow-v1"); + } + + [Fact] + public async Task FindByNameOrAliasAsync_MatchesByKey_CaseInsensitive() + { + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "Text." }; + await _sut.SaveTopicAsync(topic, TestContext.Current.CancellationToken); + + var found = await _sut.FindByNameOrAliasAsync("WIZARD", TestContext.Current.CancellationToken); + + found.Should().NotBeNull(); + found!.Id.Should().Be(topic.Id); + } + + [Fact] + public async Task FindByNameOrAliasAsync_MatchesByAlias() + { + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "Text." }; + topic.SetAliases(["mage"]); + await _sut.SaveTopicAsync(topic, TestContext.Current.CancellationToken); + + var found = await _sut.FindByNameOrAliasAsync("mage", TestContext.Current.CancellationToken); + + found.Should().NotBeNull(); + found!.Id.Should().Be(topic.Id); + } + + [Fact] + public async Task FindByNameOrAliasAsync_ReturnsNull_WhenNoMatch() + { + var found = await _sut.FindByNameOrAliasAsync("nobody", TestContext.Current.CancellationToken); + + found.Should().BeNull(); + } + + [Fact] + public async Task FindByKeywordAsync_ReturnsMatchingTopics() + { + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "Text." }; + topic.SetKeywords(["magic"]); + await _sut.SaveTopicAsync(topic, TestContext.Current.CancellationToken); + + var found = await _sut.FindByKeywordAsync("magic", TestContext.Current.CancellationToken); + + found.Should().ContainSingle().Which.Id.Should().Be(topic.Id); + } + + [Fact] + public async Task FindByKeywordAsync_OrdersMultipleMatches_ByKey() + { + var zebra = new HelpTopic { Id = HelpTopicId.New(), Key = "zebra-topic", Body = "Text." }; + zebra.SetKeywords(["magic"]); + var alpha = new HelpTopic { Id = HelpTopicId.New(), Key = "alpha-topic", Body = "Text." }; + alpha.SetKeywords(["magic"]); + await _sut.SaveTopicAsync(zebra, TestContext.Current.CancellationToken); + await _sut.SaveTopicAsync(alpha, TestContext.Current.CancellationToken); + + var found = await _sut.FindByKeywordAsync("magic", TestContext.Current.CancellationToken); + + found.Select(t => t.Key).Should().Equal("alpha-topic", "zebra-topic"); + } + + [Fact] + public async Task SaveTopicAsync_ThrowsOnDuplicateKey_WhenBypassingRepositorysOwnIdDedupe() + { + var first = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "Text." }; + await _sut.SaveTopicAsync(first, TestContext.Current.CancellationToken); + + // A distinct Id but the same Key - HelpRepository.SaveTopicAsync + // only dedupes by Id, so this simulates two concurrent creates for + // the same new key racing each other; the unique index is what + // actually prevents the duplicate row. + var second = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "Other text." }; + + var act = async () => await _sut.SaveTopicAsync(second, TestContext.Current.CancellationToken); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SaveTopicAsync_ThrowsOnDuplicateKey_EvenWhenCasingDiffers() + { + // The unique index alone (added in an earlier PR-review round) + // didn't close this race - SQLite's default collation is + // case-sensitive, but HelpRepository's own lookups + // (FindByNameOrAliasAsync) match Key case-insensitively. Without + // the NOCASE collation on Key, "wizard" and "Wizard" would pass as + // two distinct rows even though the app treats them as the same + // topic - caught in a second round of PR review. + var first = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "Text." }; + await _sut.SaveTopicAsync(first, TestContext.Current.CancellationToken); + + var second = new HelpTopic { Id = HelpTopicId.New(), Key = "Wizard", Body = "Other text." }; + + var act = async () => await _sut.SaveTopicAsync(second, TestContext.Current.CancellationToken); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeletingATopicDirectly_CascadesToItsChunks_EvenBypassingHelpRepository() + { + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "Text." }; + topic.ReplaceChunks([new HelpTopicChunk(Guid.NewGuid(), topic.Id, 0, "text", [1f], "model", "hash")]); + await _sut.SaveTopicAsync(topic, TestContext.Current.CancellationToken); + + // Deletes the HelpTopic row directly, bypassing + // HelpRepository.DeleteTopicAsync entirely (which would clean up + // chunks itself) - proves the FK's cascade delete is what actually + // prevents an orphaned chunk here, not just HelpRepository's own + // discipline. + await using (var context = await _factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) + { + var row = await context.HelpTopics.SingleAsync(t => t.Id == topic.Id, TestContext.Current.CancellationToken); + context.HelpTopics.Remove(row); + await context.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using (var context = await _factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) + { + var orphanedChunks = await context.HelpTopicChunks.Where(c => c.HelpTopicId == topic.Id).ToListAsync(TestContext.Current.CancellationToken); + orphanedChunks.Should().BeEmpty(); + } + } + + [Fact] + public async Task SaveTopicAsync_CalledTwice_ReplacesChunksWholesale() + { + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "Text." }; + topic.ReplaceChunks([new HelpTopicChunk(Guid.NewGuid(), topic.Id, 0, "old", [1f], "model-v1", "hash1")]); + await _sut.SaveTopicAsync(topic, TestContext.Current.CancellationToken); + + topic.ReplaceChunks([new HelpTopicChunk(Guid.NewGuid(), topic.Id, 0, "new", [2f], "model-v2", "hash2")]); + await _sut.SaveTopicAsync(topic, TestContext.Current.CancellationToken); + + var all = await _sut.GetAllTopicsAsync(TestContext.Current.CancellationToken); + var loaded = all.Should().ContainSingle().Subject; + loaded.Chunks.Should().ContainSingle().Which.Text.Should().Be("new"); + } + + [Fact] + public async Task DeleteTopicAsync_RemovesTopicAndItsChunks() + { + var topic = new HelpTopic { Id = HelpTopicId.New(), Key = "wizard", Body = "Text." }; + topic.ReplaceChunks([new HelpTopicChunk(Guid.NewGuid(), topic.Id, 0, "text", [1f], "model", "hash")]); + await _sut.SaveTopicAsync(topic, TestContext.Current.CancellationToken); + + await _sut.DeleteTopicAsync(topic.Id, TestContext.Current.CancellationToken); + + var all = await _sut.GetAllTopicsAsync(TestContext.Current.CancellationToken); + all.Should().BeEmpty(); + } +} diff --git a/tests/SharpMud.Ruleset.Rpg.Tests/ServiceCollectionExtensionsTests.cs b/tests/SharpMud.Ruleset.Rpg.Tests/ServiceCollectionExtensionsTests.cs index a9d2ad3..c3983e5 100644 --- a/tests/SharpMud.Ruleset.Rpg.Tests/ServiceCollectionExtensionsTests.cs +++ b/tests/SharpMud.Ruleset.Rpg.Tests/ServiceCollectionExtensionsTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using SharpMud.Engine.Commands; using SharpMud.Engine.Core; +using SharpMud.Engine.Help; using SharpMud.Engine.Ticking; namespace SharpMud.Ruleset.Rpg.Tests; @@ -17,6 +18,8 @@ public void AddSharpMudRpgRuleset_ComposesBuiltinRpgAndConsumerCommands_IntoOneR { var services = new ServiceCollection(); services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddSharpMudRpgRuleset((_, registry) => registry.RegisterOpen(new FakeConsumerCommand())); @@ -37,6 +40,8 @@ public void AddSharpMudRpgRuleset_RegistersCombatManagerAsBothItselfAndTickable_ { var services = new ServiceCollection(); services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddSharpMudRpgRuleset();