From 035866767910acb2b7ab3c8281f665200f3b18d2 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Thu, 6 Aug 2026 07:35:16 +0000 Subject: [PATCH] Add main/supporting characters, character arcs and open questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the outline could not express before: Main vs supporting. A new CharacterImportance sits alongside CharacterRole rather than inside it — role is the part a character plays (protagonist, mentor, foil), importance is how much of the book they carry, and a mentor can be either. Characters start Supporting and get promoted. Listings put main characters first. Character arcs. A main character's arc is a flat ordered list of stages, the same shape as a chapter's beats and for the same reason: an arc is a sequence of changes, not a tree. A stage can be pinned to the chapter where it lands. Nothing refuses an arc on a supporting character — demoting someone should not delete their work. Open questions. What the writer has not decided yet, hanging off a chapter outline, a character, both, or neither. They can be resolved, reopened or deleted, and resolving can append the decision to the notes of whatever the question was attached to, so it lands where the writer will re-read it. Resolved questions drop off the list unless asked for. Also adds GET /api/characters/{id}/beats — every beat a character appears in, in manuscript order, carrying each beat's chapter so the character page can link straight into that chapter's outline. Deletes are deliberately asymmetric: deleting a chapter unpins arc stages and detaches questions rather than taking them, because a plan outlives a decision about where the chapter break falls. Deleting a character or project does take their arcs and questions. All three capabilities are surfaced in the REST API, the agent toolset and the MCP server, per the one-source-of-truth rule. Two things worth flagging in the migration: EF's generated default for the new Importance column was an empty string, which does not parse back to a CharacterImportance and would have faulted every read of an existing dossier — it now defaults to Supporting, verified by migrating a database seeded on the old schema and reading the row back through the API. And the earlier migrations were renamed to the namespace EF derives from the output folder, so future `migrations add` runs stop drifting. 72 tests pass (28 new). The endpoints were also exercised over curl end to end: arc stages resolving their chapter, a character's beats across chapters, and a question attached to both a chapter and a character resolving into both sets of notes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw --- src/Novelly.Api/Agent/JsonSchema.cs | 20 + src/Novelly.Api/Agent/NovelAgentToolset.cs | 166 +++- src/Novelly.Api/Beats/BeatDtos.cs | 16 + src/Novelly.Api/Beats/BeatEndpoints.cs | 6 + src/Novelly.Api/Beats/BeatService.cs | 39 + src/Novelly.Api/Characters/Character.cs | 14 +- .../Characters/CharacterArcService.cs | 143 ++++ .../Characters/CharacterArcStage.cs | 31 + src/Novelly.Api/Characters/CharacterDtos.cs | 46 +- .../Characters/CharacterEndpoints.cs | 36 + .../Characters/CharacterImportance.cs | 13 + .../Characters/CharacterService.cs | 9 +- .../Common/NovellyServiceRegistration.cs | 3 + src/Novelly.Api/Data/INovelDbContext.cs | 3 + .../20260806023249_InitialSchema.Designer.cs | 2 +- .../20260806023249_InitialSchema.cs | 2 +- ...ReplaceOutlineWithBeatsAndTags.Designer.cs | 2 +- ...06031243_ReplaceOutlineWithBeatsAndTags.cs | 2 +- ...ImportanceArcsAndOpenQuestions.Designer.cs | 791 ++++++++++++++++++ ...CharacterImportanceArcsAndOpenQuestions.cs | 133 +++ .../Migrations/NovelDbContextModelSnapshot.cs | 138 ++- src/Novelly.Api/Data/NovelDbContext.cs | 38 + src/Novelly.Api/Program.cs | 2 + src/Novelly.Api/Questions/OpenQuestion.cs | 43 + src/Novelly.Api/Questions/OpenQuestionDtos.cs | 62 ++ .../Questions/OpenQuestionEndpoints.cs | 56 ++ .../Questions/OpenQuestionService.cs | 201 +++++ src/Novelly.Mcp/Tools/CharacterTools.cs | 70 ++ src/Novelly.Mcp/Tools/QuestionTools.cs | 92 ++ tests/Novelly.Api.Tests/CharacterArcTests.cs | 202 +++++ tests/Novelly.Api.Tests/ListingTests.cs | 2 +- .../NovelAgentServiceTests.cs | 2 +- tests/Novelly.Api.Tests/OpenQuestionTests.cs | 261 ++++++ tests/Novelly.Api.Tests/ServiceTestFixture.cs | 5 + 34 files changed, 2638 insertions(+), 13 deletions(-) create mode 100644 src/Novelly.Api/Characters/CharacterArcService.cs create mode 100644 src/Novelly.Api/Characters/CharacterArcStage.cs create mode 100644 src/Novelly.Api/Characters/CharacterImportance.cs create mode 100644 src/Novelly.Api/Data/Migrations/20260806055755_AddCharacterImportanceArcsAndOpenQuestions.Designer.cs create mode 100644 src/Novelly.Api/Data/Migrations/20260806055755_AddCharacterImportanceArcsAndOpenQuestions.cs create mode 100644 src/Novelly.Api/Questions/OpenQuestion.cs create mode 100644 src/Novelly.Api/Questions/OpenQuestionDtos.cs create mode 100644 src/Novelly.Api/Questions/OpenQuestionEndpoints.cs create mode 100644 src/Novelly.Api/Questions/OpenQuestionService.cs create mode 100644 src/Novelly.Mcp/Tools/QuestionTools.cs create mode 100644 tests/Novelly.Api.Tests/CharacterArcTests.cs create mode 100644 tests/Novelly.Api.Tests/OpenQuestionTests.cs diff --git a/src/Novelly.Api/Agent/JsonSchema.cs b/src/Novelly.Api/Agent/JsonSchema.cs index a2e5b86..bc3323c 100644 --- a/src/Novelly.Api/Agent/JsonSchema.cs +++ b/src/Novelly.Api/Agent/JsonSchema.cs @@ -114,6 +114,26 @@ public static class JsonInput }; } + /// + /// Reads a boolean flag. Models sometimes send "true" as a string even when the + /// schema says boolean, so both spellings are accepted. + /// + public static bool? Bool(JsonElement input, string name) + { + if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty(name, out var value)) + { + return null; + } + + return value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String when bool.TryParse(value.GetString(), out var flag) => flag, + _ => null + }; + } + /// /// Reads an array of strings. Returns null when the property is absent, which the /// services read as "leave the existing list alone" — distinct from an empty array, diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs index a5a3afa..1398a97 100644 --- a/src/Novelly.Api/Agent/NovelAgentToolset.cs +++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs @@ -4,6 +4,7 @@ using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Common; using Novelly.Api.Projects; +using Novelly.Api.Questions; using Novelly.Api.Scenes; using Novelly.Api.Tags; @@ -27,10 +28,12 @@ public record AgentTool( public class NovelAgentToolset( ProjectService projects, CharacterService characters, + CharacterArcService arcs, ChapterService chapters, BeatService beats, SceneService scenes, - TagService tags) + TagService tags, + OpenQuestionService questions) { private static readonly JsonSerializerOptions SerializerOptions = new() { @@ -123,6 +126,7 @@ public class NovelAgentToolset( async (projectId, input, ct) => await characters.CreateAsync(projectId, new CreateCharacterRequest( JsonInput.RequiredString(input, "name"), JsonInput.Enum(input, "role") ?? CharacterRole.Supporting, + JsonInput.Enum(input, "importance") ?? CharacterImportance.Supporting, JsonInput.String(input, "age"), JsonInput.String(input, "pronouns"), JsonInput.String(input, "occupation"), @@ -149,6 +153,7 @@ public class NovelAgentToolset( new UpdateCharacterRequest( JsonInput.String(input, "name"), JsonInput.Enum(input, "role"), + JsonInput.Enum(input, "importance"), JsonInput.String(input, "age"), JsonInput.String(input, "pronouns"), JsonInput.String(input, "occupation"), @@ -364,8 +369,162 @@ public class NovelAgentToolset( JsonInput.String(input, "location"), JsonInput.String(input, "prose"), JsonInput.Enum(input, "status")), ct)); + + yield return new AgentTool( + "get_character_beats", + "Every beat this character appears in, across the whole book, in manuscript order. " + + "Read this before revising a character — it is what they actually do on the page, " + + "as opposed to what the dossier claims about them.", + new JsonSchemaBuilder() + .Str("character_id", "Id of the character.", required: true) + .Build(), + async (_, input, ct) => await beats.ListForCharacterAsync( + JsonInput.RequiredGuid(input, "character_id"), ct)); + + yield return new AgentTool( + "get_character_arc", + "Read a main character's arc: the ordered stages of how they change. Each stage may " + + "be pinned to the chapter where it lands.", + new JsonSchemaBuilder() + .Str("character_id", "Id of the character.", required: true) + .Build(), + async (_, input, ct) => await arcs.ListAsync( + JsonInput.RequiredGuid(input, "character_id"), ct)); + + yield return new AgentTool( + "add_arc_stage", + "Add a stage to a character's arc. Arcs are for main characters — promote the " + + "character first with update_character if they are still Supporting.", + ArcStageSchema() + .Str("character_id", "Id of the character whose arc to add to.", required: true) + .Str("title", "A short handle for the change, three to five words.", required: true) + .Build(), + async (_, input, ct) => await arcs.CreateAsync( + JsonInput.RequiredGuid(input, "character_id"), + new CreateArcStageRequest( + JsonInput.RequiredString(input, "title"), + JsonInput.Int(input, "sort_order"), + JsonInput.String(input, "description"), + JsonInput.Guid(input, "chapter_id")), ct)); + + yield return new AgentTool( + "update_arc_stage", + "Revise a stage of a character's arc. Only the fields you supply change.", + ArcStageSchema() + .Str("arc_stage_id", "Id of the arc stage to update.", required: true) + .Str("title", "New title for the stage.") + .Build(), + async (_, input, ct) => await arcs.UpdateAsync( + JsonInput.RequiredGuid(input, "arc_stage_id"), + new UpdateArcStageRequest( + JsonInput.String(input, "title"), + JsonInput.Int(input, "sort_order"), + JsonInput.String(input, "description"), + JsonInput.Guid(input, "chapter_id")), ct)); + + yield return new AgentTool( + "delete_arc_stage", + "Remove a stage from a character's arc.", + new JsonSchemaBuilder() + .Str("arc_stage_id", "Id of the arc stage to delete.", required: true) + .Build(), + async (_, input, ct) => + { + await arcs.DeleteAsync(JsonInput.RequiredGuid(input, "arc_stage_id"), ct); + return new { deleted = true }; + }); + + yield return new AgentTool( + "reorder_arc_stages", + "Renumber a character's arc to match the order given. Stages left out keep their " + + "relative position after the ones listed.", + new JsonSchemaBuilder() + .Str("character_id", "Id of the character whose arc to reorder.", required: true) + .StringArray("stage_ids", "Arc stage ids in the order wanted.", required: true) + .Build(), + async (_, input, ct) => await arcs.ReorderAsync( + JsonInput.RequiredGuid(input, "character_id"), + new ReorderArcStagesRequest( + [.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct)); + + yield return new AgentTool( + "list_open_questions", + "The decisions the writer has not made yet. Read this before proposing changes — an " + + "open question is a place the writer is still thinking, not a gap to fill in for them.", + new JsonSchemaBuilder() + .Str("chapter_id", "Narrow to questions about one chapter outline.") + .Str("character_id", "Narrow to questions about one character.") + .Bool("include_resolved", "Include questions already settled. Defaults to false.") + .Build(), + async (projectId, input, ct) => await questions.ListAsync( + projectId, + JsonInput.Guid(input, "chapter_id"), + JsonInput.Guid(input, "character_id"), + JsonInput.Bool(input, "include_resolved") ?? false, + ct)); + + yield return new AgentTool( + "raise_open_question", + "Record a question the writer has not settled. Attach it to the chapter outline " + + "and/or the character it is about. Prefer raising a question over guessing when " + + "the writer has not decided something.", + new JsonSchemaBuilder() + .Str("question", "The question, in one line.", required: true) + .Str("detail", "The thinking around it — options, and what each costs.") + .Str("chapter_id", "The chapter outline this is about, if any.") + .Str("character_id", "The character this is about, if any.") + .Build(), + async (projectId, input, ct) => await questions.CreateAsync( + projectId, + new CreateOpenQuestionRequest( + JsonInput.RequiredString(input, "question"), + JsonInput.String(input, "detail"), + JsonInput.Guid(input, "chapter_id"), + JsonInput.Guid(input, "character_id")), ct)); + + yield return new AgentTool( + "resolve_open_question", + "Settle a question with what the writer decided. Set append_to_notes to also write " + + "the resolution into the notes of the chapter and character it hangs off.", + new JsonSchemaBuilder() + .Str("question_id", "Id of the question to resolve.", required: true) + .Str("resolution", "What was decided.", required: true) + .Bool("append_to_notes", "Also append the resolution to the associated notes.") + .Build(), + async (_, input, ct) => await questions.ResolveAsync( + JsonInput.RequiredGuid(input, "question_id"), + new ResolveOpenQuestionRequest( + JsonInput.RequiredString(input, "resolution"), + JsonInput.Bool(input, "append_to_notes") ?? false), ct)); + + yield return new AgentTool( + "reopen_question", + "Put a resolved question back on the list. Anything already appended to notes stays.", + new JsonSchemaBuilder() + .Str("question_id", "Id of the question to reopen.", required: true) + .Build(), + async (_, input, ct) => await questions.ReopenAsync( + JsonInput.RequiredGuid(input, "question_id"), ct)); + + yield return new AgentTool( + "delete_open_question", + "Delete a question outright. Resolving is usually better — it keeps the decision.", + new JsonSchemaBuilder() + .Str("question_id", "Id of the question to delete.", required: true) + .Build(), + async (_, input, ct) => + { + await questions.DeleteAsync(JsonInput.RequiredGuid(input, "question_id"), ct); + return new { deleted = true }; + }); } + private static JsonSchemaBuilder ArcStageSchema() => + new JsonSchemaBuilder() + .Int("sort_order", "Position in the arc. Appended to the end when omitted.") + .Str("description", "What shifts in the character here, and what it costs them.") + .Str("chapter_id", "The chapter where this stage lands, if it is pinned to one."); + private static JsonSchemaBuilder CharacterSchema(bool includeName, bool nameRequired) { var schema = new JsonSchemaBuilder(); @@ -377,6 +536,11 @@ public class NovelAgentToolset( return schema .Enum("role", "The part they play in the story.", System.Enum.GetNames()) + .Enum( + "importance", + "How much of the book they carry. Main characters are the few the story is " + + "about and are worth an arc; everyone else is Supporting.", + System.Enum.GetNames()) .Str("age", "Age, exact or approximate.") .Str("pronouns", "The pronouns this character uses.") .Str("occupation", "What they do.") diff --git a/src/Novelly.Api/Beats/BeatDtos.cs b/src/Novelly.Api/Beats/BeatDtos.cs index 598e918..169d4b3 100644 --- a/src/Novelly.Api/Beats/BeatDtos.cs +++ b/src/Novelly.Api/Beats/BeatDtos.cs @@ -38,6 +38,22 @@ public record UpdateBeatRequest( Guid? SceneId = null, IReadOnlyList? Tags = null); +/// +/// A beat this character appears in, carrying enough of its chapter to link straight to +/// the row in that chapter's outline. +/// +public record CharacterBeatDto( + Guid Id, + Guid ChapterId, + int ChapterNumber, + string ChapterTitle, + int SortOrder, + string Title, + string? WhatHappened, + string? WhatsNext, + Guid? SceneId, + string? SceneTitle); + /// Reorders a chapter's beats in one call, by listing their ids in the order wanted. public record ReorderBeatsRequest(IReadOnlyList BeatIds); diff --git a/src/Novelly.Api/Beats/BeatEndpoints.cs b/src/Novelly.Api/Beats/BeatEndpoints.cs index bc2dd66..29fd672 100644 --- a/src/Novelly.Api/Beats/BeatEndpoints.cs +++ b/src/Novelly.Api/Beats/BeatEndpoints.cs @@ -23,6 +23,12 @@ public static class BeatEndpoints Results.Ok(await service.ReorderAsync(chapterId, request, ct))) .WithSummary("Renumber a chapter's beats to match the order given."); + app.MapGet("/api/characters/{characterId:guid}/beats", async ( + Guid characterId, BeatService service, CancellationToken ct) => + Results.Ok(await service.ListForCharacterAsync(characterId, ct))) + .WithTags("Beats") + .WithSummary("Every beat this character appears in, in manuscript order."); + var beats = app.MapGroup("/api/beats").WithTags("Beats"); beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) => diff --git a/src/Novelly.Api/Beats/BeatService.cs b/src/Novelly.Api/Beats/BeatService.cs index 288047c..eacf0a3 100644 --- a/src/Novelly.Api/Beats/BeatService.cs +++ b/src/Novelly.Api/Beats/BeatService.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Novelly.Api.Chapters; +using Novelly.Api.Characters; using Novelly.Api.Common; using Novelly.Api.Data; using Novelly.Api.Tags; @@ -25,6 +26,44 @@ public class BeatService(INovelDbContext db, TagService tags) public async Task GetAsync(Guid id, CancellationToken ct = default) => (await FindAsync(id, ct)).ToDto(); + /// + /// Every beat this character appears in, in manuscript order. This is the character + /// page's view onto the outlines: each row carries its chapter so the UI can link + /// straight to the beat in that chapter's outline. + /// + public async Task> ListForCharacterAsync( + Guid characterId, CancellationToken ct = default) + { + if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct)) + { + throw new NotFoundException(nameof(Character), characterId); + } + + var beats = await db.Beats + .Include(b => b.Chapter) + .Include(b => b.Scene) + .Where(b => b.CharacterId == characterId) + .ToListAsync(ct); + + return + [ + .. beats + .OrderBy(b => b.Chapter?.Number ?? 0) + .ThenBy(b => b.SortOrder) + .Select(b => new CharacterBeatDto( + b.Id, + b.ChapterId, + b.Chapter?.Number ?? 0, + b.Chapter?.Title ?? "(unknown chapter)", + b.SortOrder, + b.Title, + b.WhatHappened, + b.WhatsNext, + b.SceneId, + b.Scene?.Title)) + ]; + } + public async Task CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default) { var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct) diff --git a/src/Novelly.Api/Characters/Character.cs b/src/Novelly.Api/Characters/Character.cs index 943b49a..35fb0d4 100644 --- a/src/Novelly.Api/Characters/Character.cs +++ b/src/Novelly.Api/Characters/Character.cs @@ -16,6 +16,12 @@ public class Character public string Name { get; set; } = string.Empty; public CharacterRole Role { get; set; } = CharacterRole.Supporting; + /// + /// Whether this character carries the book or supports it. New characters start as + /// supporting — a writer promotes the few who turn out to be main. + /// + public CharacterImportance Importance { get; set; } = CharacterImportance.Supporting; + public string? Age { get; set; } public string? Pronouns { get; set; } public string? Occupation { get; set; } @@ -33,7 +39,10 @@ public class Character public string? InternalConflict { get; set; } public string? ExternalConflict { get; set; } - /// How the character changes over the course of the book. + /// + /// How the character changes over the course of the book, in a sentence or two. + /// breaks the same change into ordered steps. + /// public string? ArcSummary { get; set; } /// Speech patterns, verbal tics, register — anything that makes dialogue sound like them. @@ -46,6 +55,9 @@ public class Character public List Relationships { get; set; } = []; public List Tags { get; set; } = []; + + /// The character's arc, in order. Kept mainly for main characters. + public List ArcStages { get; set; } = []; } /// A directed relationship from one character to another. diff --git a/src/Novelly.Api/Characters/CharacterArcService.cs b/src/Novelly.Api/Characters/CharacterArcService.cs new file mode 100644 index 0000000..9adff1e --- /dev/null +++ b/src/Novelly.Api/Characters/CharacterArcService.cs @@ -0,0 +1,143 @@ +using Microsoft.EntityFrameworkCore; +using Novelly.Api.Common; +using Novelly.Api.Data; + +namespace Novelly.Api.Characters; + +/// +/// A character's arc: a flat, ordered list of the changes they go through. Same shape as +/// a chapter's beats, and for the same reason — an arc is a sequence, not a tree. +/// +/// +/// Arcs are only really worth keeping for main characters, but nothing here refuses one +/// on a supporting character. Demoting someone should not delete work, and a character +/// who turns out to matter gets promoted after the arc is already sketched. +/// +public class CharacterArcService(INovelDbContext db) +{ + public async Task> ListAsync(Guid characterId, CancellationToken ct = default) + { + var stages = await Query() + .Where(s => s.CharacterId == characterId) + .OrderBy(s => s.SortOrder) + .ToListAsync(ct); + + return [.. stages.Select(s => s.ToDto())]; + } + + public async Task GetAsync(Guid id, CancellationToken ct = default) => + (await FindAsync(id, ct)).ToDto(); + + public async Task CreateAsync( + Guid characterId, CreateArcStageRequest request, CancellationToken ct = default) + { + var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct) + ?? throw new NotFoundException(nameof(Character), characterId); + + await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct); + + var stage = new CharacterArcStage + { + CharacterId = characterId, + Title = request.Title, + SortOrder = request.SortOrder ?? await NextSortOrderAsync(characterId, ct), + Description = request.Description, + ChapterId = request.ChapterId + }; + + db.CharacterArcStages.Add(stage); + await db.SaveChangesAsync(ct); + return (await FindAsync(stage.Id, ct)).ToDto(); + } + + public async Task UpdateAsync( + Guid id, UpdateArcStageRequest request, CancellationToken ct = default) + { + var stage = await FindAsync(id, ct); + + var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct) + ?? throw new NotFoundException(nameof(Character), stage.CharacterId); + + await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct); + + stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title; + stage.SortOrder = request.SortOrder ?? stage.SortOrder; + stage.Description = Patch.Apply(stage.Description, request.Description); + stage.ChapterId = request.ChapterId ?? stage.ChapterId; + stage.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return (await FindAsync(id, ct)).ToDto(); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var stage = await FindAsync(id, ct); + db.CharacterArcStages.Remove(stage); + await db.SaveChangesAsync(ct); + } + + /// + /// Renumbers a character's arc to match the order given. Stages left out keep their + /// relative position after the ones listed, exactly as beat reordering works. + /// + public async Task> ReorderAsync( + Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default) + { + var stages = await db.CharacterArcStages + .Where(s => s.CharacterId == characterId) + .ToListAsync(ct); + + var missing = request.StageIds.Where(id => stages.All(s => s.Id != id)).ToList(); + if (missing.Count > 0) + { + throw new NotFoundException(nameof(CharacterArcStage), missing[0]); + } + + var order = 1; + foreach (var id in request.StageIds) + { + stages.Single(s => s.Id == id).SortOrder = order++; + } + + foreach (var stage in stages.Where(s => !request.StageIds.Contains(s.Id)).OrderBy(s => s.SortOrder)) + { + stage.SortOrder = order++; + } + + await db.SaveChangesAsync(ct); + return await ListAsync(characterId, ct); + } + + private async Task EnsureChapterIsInSameProjectAsync( + Character character, Guid? chapterId, CancellationToken ct) + { + if (chapterId is not { } id) + { + return; + } + + var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.ProjectId == character.ProjectId, ct); + + if (!belongs) + { + throw new InvalidOperationException( + "An arc stage can only point at a chapter in the same project as its character."); + } + } + + private async Task NextSortOrderAsync(Guid characterId, CancellationToken ct) + { + var max = await db.CharacterArcStages + .Where(s => s.CharacterId == characterId) + .MaxAsync(s => (int?)s.SortOrder, ct); + + return (max ?? 0) + 1; + } + + private IQueryable Query() => db.CharacterArcStages.Include(s => s.Chapter); + + private async Task FindAsync(Guid id, CancellationToken ct) => + await Query().FirstOrDefaultAsync(s => s.Id == id, ct) + ?? throw new NotFoundException(nameof(CharacterArcStage), id); +} diff --git a/src/Novelly.Api/Characters/CharacterArcStage.cs b/src/Novelly.Api/Characters/CharacterArcStage.cs new file mode 100644 index 0000000..e101f6c --- /dev/null +++ b/src/Novelly.Api/Characters/CharacterArcStage.cs @@ -0,0 +1,31 @@ +using Novelly.Api.Chapters; + +namespace Novelly.Api.Characters; + +/// +/// One step in a main character's arc. Flat and ordered by , the +/// same shape as a chapter's beats — an arc is a sequence of changes, not a tree. +/// +public class CharacterArcStage +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid CharacterId { get; set; } + public Character? Character { get; set; } + + /// Position in the arc, 1-based. + public int SortOrder { get; set; } + + /// A short handle for the change — "stops covering for her brother". + public string Title { get; set; } = string.Empty; + + /// What shifts in the character here, and what it costs them. + public string? Description { get; set; } + + /// Optionally, where in the manuscript this stage lands. + public Guid? ChapterId { get; set; } + public Chapter? Chapter { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/Novelly.Api/Characters/CharacterDtos.cs b/src/Novelly.Api/Characters/CharacterDtos.cs index f6e2d07..8c78264 100644 --- a/src/Novelly.Api/Characters/CharacterDtos.cs +++ b/src/Novelly.Api/Characters/CharacterDtos.cs @@ -7,6 +7,7 @@ public record CharacterDto( Guid ProjectId, string Name, CharacterRole Role, + CharacterImportance Importance, string? Age, string? Pronouns, string? Occupation, @@ -22,6 +23,7 @@ public record CharacterDto( string? Notes, IReadOnlyList Relationships, IReadOnlyList Tags, + IReadOnlyList ArcStages, DateTimeOffset UpdatedAt); public record RelationshipDto( @@ -34,6 +36,7 @@ public record RelationshipDto( public record CreateCharacterRequest( string Name, CharacterRole Role = CharacterRole.Supporting, + CharacterImportance Importance = CharacterImportance.Supporting, string? Age = null, string? Pronouns = null, string? Occupation = null, @@ -56,6 +59,7 @@ public record CreateCharacterRequest( public record UpdateCharacterRequest( string? Name = null, CharacterRole? Role = null, + CharacterImportance? Importance = null, string? Age = null, string? Pronouns = null, string? Occupation = null, @@ -76,10 +80,38 @@ public record CreateRelationshipRequest( string RelationshipType, string? Description = null); +public record ArcStageDto( + Guid Id, + Guid CharacterId, + int SortOrder, + string Title, + string? Description, + Guid? ChapterId, + int? ChapterNumber, + string? ChapterTitle, + DateTimeOffset UpdatedAt); + +public record CreateArcStageRequest( + string Title, + int? SortOrder = null, + string? Description = null, + Guid? ChapterId = null); + +/// Patch-style update. A null field is left alone; an empty string clears it. +public record UpdateArcStageRequest( + string? Title = null, + int? SortOrder = null, + string? Description = null, + Guid? ChapterId = null); + +/// Reorders a character's arc in one call, by listing the stage ids in the order wanted. +public record ReorderArcStagesRequest(IReadOnlyList StageIds); + + public static class CharacterMapping { public static CharacterDto ToDto(this Character c) => new( - c.Id, c.ProjectId, c.Name, c.Role, c.Age, c.Pronouns, c.Occupation, + c.Id, c.ProjectId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation, c.Appearance, c.Personality, c.Backstory, c.Want, c.Need, c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes, [.. c.Relationships.Select(r => new RelationshipDto( @@ -89,5 +121,17 @@ public static class CharacterMapping r.RelationshipType, r.Description))], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())], + [.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToDto())], c.UpdatedAt); + + public static ArcStageDto ToDto(this CharacterArcStage s) => new( + s.Id, + s.CharacterId, + s.SortOrder, + s.Title, + s.Description, + s.ChapterId, + s.Chapter?.Number, + s.Chapter?.Title, + s.UpdatedAt); } diff --git a/src/Novelly.Api/Characters/CharacterEndpoints.cs b/src/Novelly.Api/Characters/CharacterEndpoints.cs index 9dda3df..1638e02 100644 --- a/src/Novelly.Api/Characters/CharacterEndpoints.cs +++ b/src/Novelly.Api/Characters/CharacterEndpoints.cs @@ -49,6 +49,42 @@ public static class CharacterEndpoints }) .WithSummary("Remove a relationship."); + characters.MapGet("/{id:guid}/arc", async ( + Guid id, CharacterArcService service, CancellationToken ct) => + Results.Ok(await service.ListAsync(id, ct))) + .WithSummary("Read a character's arc: its stages, in order."); + + characters.MapPost("/{id:guid}/arc", async ( + Guid id, CreateArcStageRequest request, CharacterArcService service, CancellationToken ct) => + { + var created = await service.CreateAsync(id, request, ct); + return Results.Created($"/api/arc-stages/{created.Id}", created); + }) + .WithSummary("Add a stage to a character's arc."); + + characters.MapPost("/{id:guid}/arc/reorder", async ( + Guid id, ReorderArcStagesRequest request, CharacterArcService service, CancellationToken ct) => + Results.Ok(await service.ReorderAsync(id, request, ct))) + .WithSummary("Renumber a character's arc to match the order given."); + + var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters"); + + arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) => + Results.Ok(await service.GetAsync(id, ct))) + .WithSummary("Read one arc stage."); + + arcStages.MapPatch("/{id:guid}", async ( + Guid id, UpdateArcStageRequest request, CharacterArcService service, CancellationToken ct) => + Results.Ok(await service.UpdateAsync(id, request, ct))) + .WithSummary("Update an arc stage."); + + arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) => + { + await service.DeleteAsync(id, ct); + return Results.NoContent(); + }) + .WithSummary("Delete an arc stage."); + return app; } } diff --git a/src/Novelly.Api/Characters/CharacterImportance.cs b/src/Novelly.Api/Characters/CharacterImportance.cs new file mode 100644 index 0000000..537bc40 --- /dev/null +++ b/src/Novelly.Api/Characters/CharacterImportance.cs @@ -0,0 +1,13 @@ +namespace Novelly.Api.Characters; + +/// +/// How much of the book a character carries. This is separate from +/// : role is the part they play in the story (protagonist, +/// mentor, foil), importance is how much weight they take. A mentor can be either. +/// Main characters are the ones worth tracking an arc for. +/// +public enum CharacterImportance +{ + Main, + Supporting +} diff --git a/src/Novelly.Api/Characters/CharacterService.cs b/src/Novelly.Api/Characters/CharacterService.cs index fe2d599..03b1161 100644 --- a/src/Novelly.Api/Characters/CharacterService.cs +++ b/src/Novelly.Api/Characters/CharacterService.cs @@ -12,7 +12,8 @@ public class CharacterService(INovelDbContext db, TagService tags) { var characters = await Query() .Where(c => c.ProjectId == projectId) - .OrderBy(c => c.Role) + .OrderBy(c => c.Importance) + .ThenBy(c => c.Role) .ThenBy(c => c.Name) .ToListAsync(ct); @@ -31,6 +32,7 @@ public class CharacterService(INovelDbContext db, TagService tags) ProjectId = projectId, Name = request.Name, Role = request.Role, + Importance = request.Importance, Age = request.Age, Pronouns = request.Pronouns, Occupation = request.Occupation, @@ -62,6 +64,7 @@ public class CharacterService(INovelDbContext db, TagService tags) character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name; character.Role = request.Role ?? character.Role; + character.Importance = request.Importance ?? character.Importance; character.Age = Patch.Apply(character.Age, request.Age); character.Pronouns = Patch.Apply(character.Pronouns, request.Pronouns); character.Occupation = Patch.Apply(character.Occupation, request.Occupation); @@ -133,7 +136,9 @@ public class CharacterService(INovelDbContext db, TagService tags) db.Characters .Include(c => c.Relationships) .ThenInclude(r => r.RelatedCharacter) - .Include(c => c.Tags); + .Include(c => c.Tags) + .Include(c => c.ArcStages) + .ThenInclude(s => s.Chapter); private async Task FindAsync(Guid id, CancellationToken ct) => await Query().FirstOrDefaultAsync(c => c.Id == id, ct) diff --git a/src/Novelly.Api/Common/NovellyServiceRegistration.cs b/src/Novelly.Api/Common/NovellyServiceRegistration.cs index 2f753a5..42109db 100644 --- a/src/Novelly.Api/Common/NovellyServiceRegistration.cs +++ b/src/Novelly.Api/Common/NovellyServiceRegistration.cs @@ -5,6 +5,7 @@ using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Data; using Novelly.Api.Projects; +using Novelly.Api.Questions; using Novelly.Api.Scenes; using Novelly.Api.Tags; @@ -27,10 +28,12 @@ public static class NovellyServiceRegistration services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Novelly.Api/Data/INovelDbContext.cs b/src/Novelly.Api/Data/INovelDbContext.cs index db25917..9d6ba42 100644 --- a/src/Novelly.Api/Data/INovelDbContext.cs +++ b/src/Novelly.Api/Data/INovelDbContext.cs @@ -4,6 +4,7 @@ using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Projects; +using Novelly.Api.Questions; using Novelly.Api.Scenes; using Novelly.Api.Tags; @@ -18,10 +19,12 @@ public interface INovelDbContext DbSet Projects { get; } DbSet Characters { get; } DbSet CharacterRelationships { get; } + DbSet CharacterArcStages { get; } DbSet Beats { get; } DbSet Tags { get; } DbSet Chapters { get; } DbSet Scenes { get; } + DbSet OpenQuestions { get; } DbSet Conversations { get; } DbSet AgentMessages { get; } diff --git a/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.Designer.cs b/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.Designer.cs index 5866962..34c50db 100644 --- a/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.Designer.cs +++ b/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.Designer.cs @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace Novelly.Api.Migrations +namespace Novelly.Api.Data.Migrations { [DbContext(typeof(NovelDbContext))] [Migration("20260806023249_InitialSchema")] diff --git a/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.cs b/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.cs index 8aa7c43..06155df 100644 --- a/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.cs +++ b/src/Novelly.Api/Data/Migrations/20260806023249_InitialSchema.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace Novelly.Api.Migrations +namespace Novelly.Api.Data.Migrations { /// public partial class InitialSchema : Migration diff --git a/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.Designer.cs b/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.Designer.cs index 0d8a658..b0a8025 100644 --- a/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.Designer.cs +++ b/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.Designer.cs @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace Novelly.Api.Migrations +namespace Novelly.Api.Data.Migrations { [DbContext(typeof(NovelDbContext))] [Migration("20260806031243_ReplaceOutlineWithBeatsAndTags")] diff --git a/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.cs b/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.cs index 2cc6367..11b3d5a 100644 --- a/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.cs +++ b/src/Novelly.Api/Data/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace Novelly.Api.Migrations +namespace Novelly.Api.Data.Migrations { /// public partial class ReplaceOutlineWithBeatsAndTags : Migration diff --git a/src/Novelly.Api/Data/Migrations/20260806055755_AddCharacterImportanceArcsAndOpenQuestions.Designer.cs b/src/Novelly.Api/Data/Migrations/20260806055755_AddCharacterImportanceArcsAndOpenQuestions.Designer.cs new file mode 100644 index 0000000..008ea46 --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260806055755_AddCharacterImportanceArcsAndOpenQuestions.Designer.cs @@ -0,0 +1,791 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Novelly.Api.Data; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + [DbContext(typeof(NovelDbContext))] + [Migration("20260806055755_AddCharacterImportanceArcsAndOpenQuestions")] + partial class AddCharacterImportanceArcsAndOpenQuestions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("BeatTag", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("BeatTags", (string)null); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTags", (string)null); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("CharactersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("CharacterTags", (string)null); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("ToolCallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Sequence") + .IsUnique(); + + b.ToTable("AgentMessages"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("SceneId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WhatHappened") + .HasColumnType("TEXT"); + + b.Property("WhatsNext") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("SceneId"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("PovCharacterId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Setting") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PovCharacterId"); + + b.HasIndex("ProjectId", "Number"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Age") + .HasColumnType("TEXT"); + + b.Property("Appearance") + .HasColumnType("TEXT"); + + b.Property("ArcSummary") + .HasColumnType("TEXT"); + + b.Property("Backstory") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExternalConflict") + .HasColumnType("TEXT"); + + b.Property("Importance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("InternalConflict") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Need") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Occupation") + .HasColumnType("TEXT"); + + b.Property("Personality") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Pronouns") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Voice") + .HasColumnType("TEXT"); + + b.Property("Want") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId", "SortOrder"); + + b.ToTable("CharacterArcStages"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("RelatedCharacterId") + .HasColumnType("TEXT"); + + b.Property("RelationshipType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RelatedCharacterId"); + + b.ToTable("CharacterRelationships"); + }); + + modelBuilder.Entity("Novelly.Api.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasColumnType("TEXT"); + + b.Property("Logline") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Synopsis") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Detail") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Resolution") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("ProjectId"); + + b.ToTable("OpenQuestions"); + }); + + modelBuilder.Entity("Novelly.Api.Scenes.Scene", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("Conflict") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Goal") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Outcome") + .HasColumnType("TEXT"); + + b.Property("PovCharacterId") + .HasColumnType("TEXT"); + + b.Property("Prose") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PovCharacterId"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Scenes"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Conversations") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany("Beats") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Scenes.Scene", "Scene") + .WithMany() + .HasForeignKey("SceneId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + + b.Navigation("Scene"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.HasOne("Novelly.Api.Characters.Character", "PovCharacter") + .WithMany() + .HasForeignKey("PovCharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Chapters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PovCharacter"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Characters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("ArcStages") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("Relationships") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter") + .WithMany() + .HasForeignKey("RelatedCharacterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Character"); + + b.Navigation("RelatedCharacter"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Scenes.Scene", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany("Scenes") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "PovCharacter") + .WithMany() + .HasForeignKey("PovCharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Chapter"); + + b.Navigation("PovCharacter"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Tags") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Navigation("Beats"); + + b.Navigation("Scenes"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Navigation("ArcStages"); + + b.Navigation("Relationships"); + }); + + modelBuilder.Entity("Novelly.Api.Projects.Project", b => + { + b.Navigation("Chapters"); + + b.Navigation("Characters"); + + b.Navigation("Conversations"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/20260806055755_AddCharacterImportanceArcsAndOpenQuestions.cs b/src/Novelly.Api/Data/Migrations/20260806055755_AddCharacterImportanceArcsAndOpenQuestions.cs new file mode 100644 index 0000000..65dc3ed --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260806055755_AddCharacterImportanceArcsAndOpenQuestions.cs @@ -0,0 +1,133 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + /// + public partial class AddCharacterImportanceArcsAndOpenQuestions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Existing characters predate the main/supporting distinction. EF's generated + // default is an empty string, which does not parse back to a CharacterImportance + // and would fault every read of an existing dossier — everyone starts Supporting. + migrationBuilder.AddColumn( + name: "Importance", + table: "Characters", + type: "TEXT", + maxLength: 32, + nullable: false, + defaultValue: "Supporting"); + + migrationBuilder.CreateTable( + name: "CharacterArcStages", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + CharacterId = table.Column(type: "TEXT", nullable: false), + SortOrder = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Description = table.Column(type: "TEXT", nullable: true), + ChapterId = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CharacterArcStages", x => x.Id); + table.ForeignKey( + name: "FK_CharacterArcStages_Chapters_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapters", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_CharacterArcStages_Characters_CharacterId", + column: x => x.CharacterId, + principalTable: "Characters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "OpenQuestions", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ProjectId = table.Column(type: "TEXT", nullable: false), + Question = table.Column(type: "TEXT", maxLength: 500, nullable: false), + Detail = table.Column(type: "TEXT", nullable: true), + ChapterId = table.Column(type: "TEXT", nullable: true), + CharacterId = table.Column(type: "TEXT", nullable: true), + Resolution = table.Column(type: "TEXT", nullable: true), + ResolvedAt = table.Column(type: "INTEGER", nullable: true), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenQuestions", x => x.Id); + table.ForeignKey( + name: "FK_OpenQuestions_Chapters_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapters", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_OpenQuestions_Characters_CharacterId", + column: x => x.CharacterId, + principalTable: "Characters", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_OpenQuestions_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CharacterArcStages_ChapterId", + table: "CharacterArcStages", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_CharacterArcStages_CharacterId_SortOrder", + table: "CharacterArcStages", + columns: new[] { "CharacterId", "SortOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenQuestions_ChapterId", + table: "OpenQuestions", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_OpenQuestions_CharacterId", + table: "OpenQuestions", + column: "CharacterId"); + + migrationBuilder.CreateIndex( + name: "IX_OpenQuestions_ProjectId", + table: "OpenQuestions", + column: "ProjectId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CharacterArcStages"); + + migrationBuilder.DropTable( + name: "OpenQuestions"); + + migrationBuilder.DropColumn( + name: "Importance", + table: "Characters"); + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs index 626b396..90b4458 100644 --- a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs +++ b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs @@ -2,12 +2,12 @@ using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; -using Novelly.Api.Data; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Novelly.Api.Data; #nullable disable -namespace Novelly.Api.Migrations +namespace Novelly.Api.Data.Migrations { [DbContext(typeof(NovelDbContext))] partial class NovelDbContextModelSnapshot : ModelSnapshot @@ -246,6 +246,11 @@ namespace Novelly.Api.Migrations b.Property("ExternalConflict") .HasColumnType("TEXT"); + b.Property("Importance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + b.Property("InternalConflict") .HasColumnType("TEXT"); @@ -293,6 +298,44 @@ namespace Novelly.Api.Migrations b.ToTable("Characters"); }); + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId", "SortOrder"); + + b.ToTable("CharacterArcStages"); + }); + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => { b.Property("Id") @@ -362,6 +405,52 @@ namespace Novelly.Api.Migrations b.ToTable("Projects"); }); + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Detail") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Resolution") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("ProjectId"); + + b.ToTable("OpenQuestions"); + }); + modelBuilder.Entity("Novelly.Api.Scenes.Scene", b => { b.Property("Id") @@ -573,6 +662,24 @@ namespace Novelly.Api.Migrations b.Navigation("Project"); }); + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("ArcStages") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + }); + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => { b.HasOne("Novelly.Api.Characters.Character", "Character") @@ -592,6 +699,31 @@ namespace Novelly.Api.Migrations b.Navigation("RelatedCharacter"); }); + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + + b.Navigation("Project"); + }); + modelBuilder.Entity("Novelly.Api.Scenes.Scene", b => { b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") @@ -635,6 +767,8 @@ namespace Novelly.Api.Migrations modelBuilder.Entity("Novelly.Api.Characters.Character", b => { + b.Navigation("ArcStages"); + b.Navigation("Relationships"); }); diff --git a/src/Novelly.Api/Data/NovelDbContext.cs b/src/Novelly.Api/Data/NovelDbContext.cs index 07cf092..d8b99f2 100644 --- a/src/Novelly.Api/Data/NovelDbContext.cs +++ b/src/Novelly.Api/Data/NovelDbContext.cs @@ -5,6 +5,7 @@ using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Projects; +using Novelly.Api.Questions; using Novelly.Api.Scenes; using Novelly.Api.Tags; @@ -27,10 +28,12 @@ public class NovelDbContext(DbContextOptions options) public DbSet Projects => Set(); public DbSet Characters => Set(); public DbSet CharacterRelationships => Set(); + public DbSet CharacterArcStages => Set(); public DbSet Beats => Set(); public DbSet Tags => Set(); public DbSet Chapters => Set(); public DbSet Scenes => Set(); + public DbSet OpenQuestions => Set(); public DbSet Conversations => Set(); public DbSet AgentMessages => Set(); @@ -59,10 +62,25 @@ public class NovelDbContext(DbContextOptions options) { entity.Property(c => c.Name).IsRequired().HasMaxLength(200); entity.Property(c => c.Role).HasConversion().HasMaxLength(32); + entity.Property(c => c.Importance).HasConversion().HasMaxLength(32); entity.HasIndex(c => c.ProjectId); entity.HasMany(c => c.Relationships).WithOne(r => r.Character!) .HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(c => c.ArcStages).WithOne(s => s.Character!) + .HasForeignKey(s => s.CharacterId).OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.Property(s => s.Title).IsRequired().HasMaxLength(200); + entity.HasIndex(s => new { s.CharacterId, s.SortOrder }); + + // An arc stage outlives the chapter it was pinned to: deleting a chapter is a + // decision about the manuscript, not about how the character changes. + entity.HasOne(s => s.Chapter).WithMany() + .HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull); }); builder.Entity(entity => @@ -133,6 +151,26 @@ public class NovelDbContext(DbContextOptions options) .HasForeignKey(s => s.PovCharacterId).OnDelete(DeleteBehavior.SetNull); }); + builder.Entity(entity => + { + entity.Property(q => q.Question).IsRequired().HasMaxLength(500); + entity.Ignore(q => q.IsResolved); + + // Open questions are listed per project and filtered to a chapter or character, + // so index the project and let the filters narrow from there. + entity.HasIndex(q => q.ProjectId); + + entity.HasOne(q => q.Project).WithMany() + .HasForeignKey(q => q.ProjectId).OnDelete(DeleteBehavior.Cascade); + + // A question survives what it was about. Deleting a chapter or character should + // not quietly take an unresolved decision with it. + entity.HasOne(q => q.Chapter).WithMany() + .HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull); + entity.HasOne(q => q.Character).WithMany() + .HasForeignKey(q => q.CharacterId).OnDelete(DeleteBehavior.SetNull); + }); + builder.Entity(entity => { entity.Property(c => c.Title).IsRequired().HasMaxLength(200); diff --git a/src/Novelly.Api/Program.cs b/src/Novelly.Api/Program.cs index 9670100..2b32782 100644 --- a/src/Novelly.Api/Program.cs +++ b/src/Novelly.Api/Program.cs @@ -8,6 +8,7 @@ using Novelly.Api.Characters; using Novelly.Api.Common; using Novelly.Api.Data; using Novelly.Api.Projects; +using Novelly.Api.Questions; using Novelly.Api.Scenes; using Novelly.Api.Tags; @@ -79,6 +80,7 @@ app.MapProjectEndpoints() .MapBeatEndpoints() .MapSceneEndpoints() .MapTagEndpoints() + .MapOpenQuestionEndpoints() .MapAgentEndpoints(); app.Run(); diff --git a/src/Novelly.Api/Questions/OpenQuestion.cs b/src/Novelly.Api/Questions/OpenQuestion.cs new file mode 100644 index 0000000..3c6c9a0 --- /dev/null +++ b/src/Novelly.Api/Questions/OpenQuestion.cs @@ -0,0 +1,43 @@ +using Novelly.Api.Chapters; +using Novelly.Api.Characters; +using Novelly.Api.Projects; + +namespace Novelly.Api.Questions; + +/// +/// Something the writer has not decided yet — "does she know about the letter before the +/// harbour?". Questions hang off the chapter outline or the character they belong to, or +/// both, or neither when they are about the book as a whole. +/// +public class OpenQuestion +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid ProjectId { get; set; } + public Project? Project { get; set; } + + /// The question itself, in one line. + public string Question { get; set; } = string.Empty; + + /// Room for the thinking around it — options considered, what each costs. + public string? Detail { get; set; } + + /// The chapter outline this question is about, if it is about one. + public Guid? ChapterId { get; set; } + public Chapter? Chapter { get; set; } + + /// The character this question is about, if it is about one. + public Guid? CharacterId { get; set; } + public Character? Character { get; set; } + + /// What was decided. Set when the question is resolved, cleared when reopened. + public string? Resolution { get; set; } + + /// When it was decided. Null while the question is still open. + public DateTimeOffset? ResolvedAt { get; set; } + + public bool IsResolved => ResolvedAt is not null; + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/Novelly.Api/Questions/OpenQuestionDtos.cs b/src/Novelly.Api/Questions/OpenQuestionDtos.cs new file mode 100644 index 0000000..98b1661 --- /dev/null +++ b/src/Novelly.Api/Questions/OpenQuestionDtos.cs @@ -0,0 +1,62 @@ +namespace Novelly.Api.Questions; + +public record OpenQuestionDto( + Guid Id, + Guid ProjectId, + string Question, + string? Detail, + Guid? ChapterId, + int? ChapterNumber, + string? ChapterTitle, + Guid? CharacterId, + string? CharacterName, + string? Resolution, + bool IsResolved, + DateTimeOffset? ResolvedAt, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public record CreateOpenQuestionRequest( + string Question, + string? Detail = null, + Guid? ChapterId = null, + Guid? CharacterId = null); + +/// +/// Patch-style update. A null field is left alone; an empty string clears it. Use +/// / to detach a question, since a +/// null id already means "leave the association alone". +/// +public record UpdateOpenQuestionRequest( + string? Question = null, + string? Detail = null, + Guid? ChapterId = null, + Guid? CharacterId = null, + bool ClearChapter = false, + bool ClearCharacter = false); + +/// +/// Settles a question. The resolution is kept on the question itself; setting +/// also appends it to the notes of whatever the question is +/// attached to, so the decision lands where the writer will actually re-read it. +/// +public record ResolveOpenQuestionRequest(string Resolution, bool AppendToNotes = false); + +public static class OpenQuestionMapping +{ + public static OpenQuestionDto ToDto(this OpenQuestion q) => new( + q.Id, + q.ProjectId, + q.Question, + q.Detail, + q.ChapterId, + q.Chapter?.Number, + q.Chapter?.Title, + q.CharacterId, + q.Character?.Name, + q.Resolution, + q.IsResolved, + q.ResolvedAt, + q.CreatedAt, + q.UpdatedAt); +} diff --git a/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs b/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs new file mode 100644 index 0000000..5ae02f2 --- /dev/null +++ b/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs @@ -0,0 +1,56 @@ +namespace Novelly.Api.Questions; + +public static class OpenQuestionEndpoints +{ + public static IEndpointRouteBuilder MapOpenQuestionEndpoints(this IEndpointRouteBuilder app) + { + var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/questions").WithTags("Questions"); + + projectScoped.MapGet("/", async ( + Guid projectId, + OpenQuestionService service, + CancellationToken ct, + Guid? chapterId = null, + Guid? characterId = null, + bool includeResolved = false) => + Results.Ok(await service.ListAsync(projectId, chapterId, characterId, includeResolved, ct))) + .WithSummary("List a project's open questions, optionally narrowed to one chapter or character."); + + projectScoped.MapPost("/", async ( + Guid projectId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => + { + var created = await service.CreateAsync(projectId, request, ct); + return Results.Created($"/api/questions/{created.Id}", created); + }) + .WithSummary("Raise an open question, optionally against a chapter outline and/or a character."); + + var questions = app.MapGroup("/api/questions").WithTags("Questions"); + + questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) => + Results.Ok(await service.GetAsync(id, ct))) + .WithSummary("Read one question."); + + questions.MapPatch("/{id:guid}", async ( + Guid id, UpdateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => + Results.Ok(await service.UpdateAsync(id, request, ct))) + .WithSummary("Update a question or change what it is attached to."); + + questions.MapPost("/{id:guid}/resolve", async ( + Guid id, ResolveOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => + Results.Ok(await service.ResolveAsync(id, request, ct))) + .WithSummary("Settle a question, optionally appending the resolution to the notes it hangs off."); + + questions.MapPost("/{id:guid}/reopen", async (Guid id, OpenQuestionService service, CancellationToken ct) => + Results.Ok(await service.ReopenAsync(id, ct))) + .WithSummary("Put a resolved question back on the list."); + + questions.MapDelete("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) => + { + await service.DeleteAsync(id, ct); + return Results.NoContent(); + }) + .WithSummary("Delete a question."); + + return app; + } +} diff --git a/src/Novelly.Api/Questions/OpenQuestionService.cs b/src/Novelly.Api/Questions/OpenQuestionService.cs new file mode 100644 index 0000000..9b775e8 --- /dev/null +++ b/src/Novelly.Api/Questions/OpenQuestionService.cs @@ -0,0 +1,201 @@ +using Microsoft.EntityFrameworkCore; +using Novelly.Api.Chapters; +using Novelly.Api.Characters; +using Novelly.Api.Common; +using Novelly.Api.Data; +using Novelly.Api.Projects; + +namespace Novelly.Api.Questions; + +/// +/// The project's open questions — the decisions still outstanding. A question can be +/// attached to a chapter outline, a character, both, or neither. +/// +public class OpenQuestionService(INovelDbContext db) +{ + /// + /// Lists a project's questions, open ones first and newest first within each group. + /// Filters narrow to what one page cares about; resolved questions are left out + /// unless asked for, since the point of the list is what is still undecided. + /// + public async Task> ListAsync( + Guid projectId, + Guid? chapterId = null, + Guid? characterId = null, + bool includeResolved = false, + CancellationToken ct = default) + { + var query = Query().Where(q => q.ProjectId == projectId); + + if (chapterId is { } cid) + { + query = query.Where(q => q.ChapterId == cid); + } + + if (characterId is { } chid) + { + query = query.Where(q => q.CharacterId == chid); + } + + if (!includeResolved) + { + query = query.Where(q => q.ResolvedAt == null); + } + + var questions = await query.ToListAsync(ct); + + return + [ + .. questions + .OrderBy(q => q.ResolvedAt is not null) + .ThenByDescending(q => q.CreatedAt) + .Select(q => q.ToDto()) + ]; + } + + public async Task GetAsync(Guid id, CancellationToken ct = default) => + (await FindAsync(id, ct)).ToDto(); + + public async Task CreateAsync( + Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default) + { + if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) + { + throw new NotFoundException(nameof(Project), projectId); + } + + if (string.IsNullOrWhiteSpace(request.Question)) + { + throw new ArgumentException("A question needs to say something."); + } + + await ValidateAssociationsAsync(projectId, request.ChapterId, request.CharacterId, ct); + + var question = new OpenQuestion + { + ProjectId = projectId, + Question = request.Question.Trim(), + Detail = request.Detail, + ChapterId = request.ChapterId, + CharacterId = request.CharacterId + }; + + db.OpenQuestions.Add(question); + await db.SaveChangesAsync(ct); + return (await FindAsync(question.Id, ct)).ToDto(); + } + + public async Task UpdateAsync( + Guid id, UpdateOpenQuestionRequest request, CancellationToken ct = default) + { + var question = await FindAsync(id, ct); + + await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct); + + question.Question = Patch.Apply(question.Question, request.Question) ?? question.Question; + question.Detail = Patch.Apply(question.Detail, request.Detail); + question.ChapterId = request.ClearChapter ? null : request.ChapterId ?? question.ChapterId; + question.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? question.CharacterId; + question.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return (await FindAsync(id, ct)).ToDto(); + } + + /// + /// Settles a question. With AppendToNotes the resolution is also appended to the + /// notes of the chapter and character it hangs off, so the decision ends up where the + /// writer reads rather than only in a list they have stopped looking at. + /// + public async Task ResolveAsync( + Guid id, ResolveOpenQuestionRequest request, CancellationToken ct = default) + { + var question = await FindAsync(id, ct); + + if (string.IsNullOrWhiteSpace(request.Resolution)) + { + throw new ArgumentException("A resolution needs to say what was decided."); + } + + question.Resolution = request.Resolution.Trim(); + question.ResolvedAt = DateTimeOffset.UtcNow; + question.UpdatedAt = question.ResolvedAt.Value; + + if (request.AppendToNotes) + { + var note = $"{question.Question} — {question.Resolution}"; + + if (question.ChapterId is { } chapterId) + { + var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct); + if (chapter is not null) + { + chapter.Notes = AppendNote(chapter.Notes, note); + chapter.UpdatedAt = DateTimeOffset.UtcNow; + } + } + + if (question.CharacterId is { } characterId) + { + var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct); + if (character is not null) + { + character.Notes = AppendNote(character.Notes, note); + character.UpdatedAt = DateTimeOffset.UtcNow; + } + } + } + + await db.SaveChangesAsync(ct); + return (await FindAsync(id, ct)).ToDto(); + } + + /// Puts a question back on the list. The resolution goes; anything already appended to notes stays. + public async Task ReopenAsync(Guid id, CancellationToken ct = default) + { + var question = await FindAsync(id, ct); + + question.Resolution = null; + question.ResolvedAt = null; + question.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return (await FindAsync(id, ct)).ToDto(); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var question = await FindAsync(id, ct); + db.OpenQuestions.Remove(question); + await db.SaveChangesAsync(ct); + } + + /// Blank line between entries, so appended resolutions stay readable as notes accumulate. + private static string AppendNote(string? existing, string note) => + string.IsNullOrWhiteSpace(existing) ? note : $"{existing.TrimEnd()}\n\n{note}"; + + private async Task ValidateAssociationsAsync( + Guid projectId, Guid? chapterId, Guid? characterId, CancellationToken ct) + { + if (chapterId is { } cid + && !await db.Chapters.AnyAsync(c => c.Id == cid && c.ProjectId == projectId, ct)) + { + throw new InvalidOperationException( + "A question can only be attached to a chapter in the same project."); + } + + if (characterId is { } chid + && !await db.Characters.AnyAsync(c => c.Id == chid && c.ProjectId == projectId, ct)) + { + throw new InvalidOperationException( + "A question can only be attached to a character in the same project."); + } + } + + private IQueryable Query() => + db.OpenQuestions.Include(q => q.Chapter).Include(q => q.Character); + + private async Task FindAsync(Guid id, CancellationToken ct) => + await Query().FirstOrDefaultAsync(q => q.Id == id, ct) + ?? throw new NotFoundException(nameof(OpenQuestion), id); +} diff --git a/src/Novelly.Mcp/Tools/CharacterTools.cs b/src/Novelly.Mcp/Tools/CharacterTools.cs index 194097b..7b9aad6 100644 --- a/src/Novelly.Mcp/Tools/CharacterTools.cs +++ b/src/Novelly.Mcp/Tools/CharacterTools.cs @@ -33,6 +33,8 @@ public static class CharacterTools CancellationToken ct, [Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")] string? role = null, + [Description("Main or Supporting. Main characters are the few the story is about and are worth tracking an arc for.")] + string? importance = null, [Description("Age, exact or approximate.")] string? age = null, [Description("The pronouns this character uses.")] string? pronouns = null, [Description("What they do.")] string? occupation = null, @@ -51,6 +53,7 @@ public static class CharacterTools { name, role = role ?? "Supporting", + importance = importance ?? "Supporting", age, pronouns, occupation, @@ -76,6 +79,8 @@ public static class CharacterTools [Description("New name.")] string? name = null, [Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")] string? role = null, + [Description("Main or Supporting. Main characters are the few the story is about and are worth tracking an arc for.")] + string? importance = null, [Description("Age, exact or approximate.")] string? age = null, [Description("The pronouns this character uses.")] string? pronouns = null, [Description("What they do.")] string? occupation = null, @@ -94,6 +99,7 @@ public static class CharacterTools { name, role, + importance, age, pronouns, occupation, @@ -110,6 +116,70 @@ public static class CharacterTools tags }, ct); + [McpServerTool(Name = "get_character_beats")] + [Description("Every beat this character appears in, across the whole book, in manuscript order. " + + "This is what the character actually does on the page, as opposed to what the " + + "dossier claims about them — read it before revising a character.")] + public static Task GetCharacterBeats( + NovelApiClient api, + [Description("The character's id.")] Guid characterId, + CancellationToken ct) => + api.GetAsync($"/api/characters/{characterId}/beats", ct); + + [McpServerTool(Name = "get_character_arc")] + [Description("Read a main character's arc: the ordered stages of how they change, each " + + "optionally pinned to the chapter where it lands.")] + public static Task GetCharacterArc( + NovelApiClient api, + [Description("The character's id.")] Guid characterId, + CancellationToken ct) => + api.GetAsync($"/api/characters/{characterId}/arc", ct); + + [McpServerTool(Name = "add_arc_stage")] + [Description("Add a stage to a character's arc. Arcs are kept for main characters — promote " + + "the character with update_character first if they are still Supporting.")] + public static Task AddArcStage( + NovelApiClient api, + [Description("Id of the character whose arc to add to.")] Guid characterId, + [Description("A short handle for the change, three to five words.")] string title, + CancellationToken ct, + [Description("What shifts in the character here, and what it costs them.")] string? description = null, + [Description("Id of the chapter where this stage lands, if it is pinned to one.")] Guid? chapterId = null, + [Description("Position in the arc. Appended to the end when omitted.")] int? sortOrder = null) => + api.PostAsync($"/api/characters/{characterId}/arc", + new { title, sortOrder, description, chapterId }, ct); + + [McpServerTool(Name = "update_arc_stage")] + [Description("Revise a stage of a character's arc. Only the fields you supply change.")] + public static Task UpdateArcStage( + NovelApiClient api, + [Description("The arc stage's id.")] Guid arcStageId, + CancellationToken ct, + [Description("New title for the stage.")] string? title = null, + [Description("What shifts in the character here.")] string? description = null, + [Description("Id of the chapter where this stage lands.")] Guid? chapterId = null, + [Description("Position in the arc.")] int? sortOrder = null) => + api.PatchAsync($"/api/arc-stages/{arcStageId}", + new { title, sortOrder, description, chapterId }, ct); + + [McpServerTool(Name = "delete_arc_stage")] + [Description("Remove a stage from a character's arc.")] + public static Task DeleteArcStage( + NovelApiClient api, + [Description("The arc stage's id.")] Guid arcStageId, + CancellationToken ct) => + api.DeleteAsync($"/api/arc-stages/{arcStageId}", ct); + + [McpServerTool(Name = "reorder_arc_stages")] + [Description("Renumber a character's arc to match the order given. Stages left out keep their " + + "relative position after the ones listed.")] + public static Task ReorderArcStages( + NovelApiClient api, + [Description("Id of the character whose arc to reorder.")] Guid characterId, + [Description("Arc stage ids in the order wanted.")] string[] stageIds, + CancellationToken ct) => + api.PostAsync($"/api/characters/{characterId}/arc/reorder", new { stageIds }, ct); + [McpServerTool(Name = "relate_characters")] [Description("Record a relationship from one character to another in the same project.")] public static Task RelateCharacters( diff --git a/src/Novelly.Mcp/Tools/QuestionTools.cs b/src/Novelly.Mcp/Tools/QuestionTools.cs new file mode 100644 index 0000000..d77a704 --- /dev/null +++ b/src/Novelly.Mcp/Tools/QuestionTools.cs @@ -0,0 +1,92 @@ +using System.ComponentModel; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Novelly.Mcp.Tools; + +[McpServerToolType] +public static class QuestionTools +{ + [McpServerTool(Name = "list_open_questions")] + [Description("The decisions the writer has not made yet, newest first. Read this before " + + "proposing changes — an open question marks somewhere the writer is still " + + "thinking, not a gap to fill in for them.")] + public static Task ListOpenQuestions( + NovelApiClient api, + [Description("The project's id.")] Guid projectId, + CancellationToken ct, + [Description("Narrow to questions about one chapter outline.")] Guid? chapterId = null, + [Description("Narrow to questions about one character.")] Guid? characterId = null, + [Description("Include questions already settled. Defaults to false.")] bool includeResolved = false) + { + var query = new List { $"includeResolved={includeResolved.ToString().ToLowerInvariant()}" }; + + if (chapterId is { } chapter) + { + query.Add($"chapterId={chapter}"); + } + + if (characterId is { } character) + { + query.Add($"characterId={character}"); + } + + return api.GetAsync($"/api/projects/{projectId}/questions?{string.Join('&', query)}", ct); + } + + [McpServerTool(Name = "raise_open_question")] + [Description("Record a question the writer has not settled, attached to the chapter outline " + + "and/or the character it is about. Prefer raising a question over guessing.")] + public static Task RaiseOpenQuestion( + NovelApiClient api, + [Description("The project's id.")] Guid projectId, + [Description("The question, in one line.")] string question, + CancellationToken ct, + [Description("The thinking around it — options considered, and what each costs.")] string? detail = null, + [Description("Id of the chapter outline this is about, if any.")] Guid? chapterId = null, + [Description("Id of the character this is about, if any.")] Guid? characterId = null) => + api.PostAsync($"/api/projects/{projectId}/questions", + new { question, detail, chapterId, characterId }, ct); + + [McpServerTool(Name = "update_open_question")] + [Description("Revise a question or change what it is attached to. Only the fields you supply change.")] + public static Task UpdateOpenQuestion( + NovelApiClient api, + [Description("The question's id.")] Guid questionId, + CancellationToken ct, + [Description("New wording for the question.")] string? question = null, + [Description("New detail. Pass an empty string to clear it.")] string? detail = null, + [Description("Attach to this chapter outline.")] Guid? chapterId = null, + [Description("Attach to this character.")] Guid? characterId = null, + [Description("Detach from its chapter.")] bool clearChapter = false, + [Description("Detach from its character.")] bool clearCharacter = false) => + api.PatchAsync($"/api/questions/{questionId}", + new { question, detail, chapterId, characterId, clearChapter, clearCharacter }, ct); + + [McpServerTool(Name = "resolve_open_question")] + [Description("Settle a question with what the writer decided. Set appendToNotes to also write " + + "the resolution into the notes of the chapter and character it hangs off.")] + public static Task ResolveOpenQuestion( + NovelApiClient api, + [Description("The question's id.")] Guid questionId, + [Description("What was decided.")] string resolution, + CancellationToken ct, + [Description("Also append the resolution to the associated notes.")] bool appendToNotes = false) => + api.PostAsync($"/api/questions/{questionId}/resolve", new { resolution, appendToNotes }, ct); + + [McpServerTool(Name = "reopen_question")] + [Description("Put a resolved question back on the list. Anything already appended to notes stays.")] + public static Task ReopenQuestion( + NovelApiClient api, + [Description("The question's id.")] Guid questionId, + CancellationToken ct) => + api.PostAsync($"/api/questions/{questionId}/reopen", new { }, ct); + + [McpServerTool(Name = "delete_open_question")] + [Description("Delete a question outright. Resolving is usually better — it keeps the decision.")] + public static Task DeleteOpenQuestion( + NovelApiClient api, + [Description("The question's id.")] Guid questionId, + CancellationToken ct) => + api.DeleteAsync($"/api/questions/{questionId}", ct); +} diff --git a/tests/Novelly.Api.Tests/CharacterArcTests.cs b/tests/Novelly.Api.Tests/CharacterArcTests.cs new file mode 100644 index 0000000..ccde294 --- /dev/null +++ b/tests/Novelly.Api.Tests/CharacterArcTests.cs @@ -0,0 +1,202 @@ +using Novelly.Api.Beats; +using Novelly.Api.Chapters; +using Novelly.Api.Characters; +using Novelly.Api.Common; +using Novelly.Api.Projects; + +namespace Novelly.Api.Tests; + +[TestFixture] +public class CharacterArcTests : ServiceTestFixture +{ + private Guid _projectId; + private Guid _characterId; + + protected override void OnSetUp() + { + _projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; + _characterId = Characters.CreateAsync( + _projectId, + new CreateCharacterRequest("Ines", Importance: CharacterImportance.Main)).Result.Id; + } + + [Test] + public async Task A_character_is_supporting_until_promoted() + { + var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + + Assert.That(mara.Importance, Is.EqualTo(CharacterImportance.Supporting)); + + var promoted = await Characters.UpdateAsync( + mara.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main)); + + Assert.That(promoted.Importance, Is.EqualTo(CharacterImportance.Main)); + } + + [Test] + public async Task Importance_is_separate_from_the_part_a_character_plays() + { + var mentor = await Characters.CreateAsync(_projectId, new CreateCharacterRequest( + "Anders", CharacterRole.Mentor, CharacterImportance.Main)); + + Assert.Multiple(() => + { + Assert.That(mentor.Role, Is.EqualTo(CharacterRole.Mentor)); + Assert.That(mentor.Importance, Is.EqualTo(CharacterImportance.Main)); + }); + } + + [Test] + public async Task Main_characters_are_listed_before_supporting_ones() + { + await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Zeno")); + await Characters.CreateAsync( + _projectId, new CreateCharacterRequest("Mara", Importance: CharacterImportance.Main)); + + var listed = await Characters.ListAsync(_projectId); + + Assert.That( + listed.Select(c => c.Name), + Is.EqualTo(new[] { "Ines", "Mara", "Zeno" })); + } + + [Test] + public async Task Arc_stages_are_appended_in_order_and_read_back_that_way() + { + await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("She trusts the map")); + await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("The map is wrong")); + await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("She navigates by memory")); + + var arc = await Arcs.ListAsync(_characterId); + + Assert.Multiple(() => + { + Assert.That( + arc.Select(s => s.Title), + Is.EqualTo(new[] { "She trusts the map", "The map is wrong", "She navigates by memory" })); + Assert.That(arc.Select(s => s.SortOrder), Is.EqualTo(new[] { 1, 2, 3 })); + }); + } + + [Test] + public async Task A_character_dossier_carries_its_arc() + { + await Arcs.CreateAsync(_characterId, new CreateArcStageRequest( + "She trusts the map", Description: "Because her mother drew it.")); + + var character = await Characters.GetAsync(_characterId); + + Assert.Multiple(() => + { + Assert.That(character.ArcStages, Has.Count.EqualTo(1)); + Assert.That(character.ArcStages[0].Description, Does.Contain("her mother drew it")); + }); + } + + [Test] + public async Task Stages_left_out_of_a_reorder_keep_their_relative_position_at_the_end() + { + var first = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("First")); + await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Second")); + var third = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Third")); + + var reordered = await Arcs.ReorderAsync( + _characterId, new ReorderArcStagesRequest([third.Id, first.Id])); + + Assert.That( + reordered.Select(s => s.Title), + Is.EqualTo(new[] { "Third", "First", "Second" })); + } + + [Test] + public async Task A_stage_pinned_to_a_chapter_resolves_that_chapter() + { + var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + + var stage = await Arcs.CreateAsync( + _characterId, new CreateArcStageRequest("The map is wrong", ChapterId: chapter.Id)); + + Assert.Multiple(() => + { + Assert.That(stage.ChapterTitle, Is.EqualTo("Landfall")); + Assert.That(stage.ChapterNumber, Is.EqualTo(1)); + }); + } + + [Test] + public async Task A_stage_cannot_be_pinned_to_a_chapter_from_another_project() + { + var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); + var elsewhere = await Chapters.CreateAsync(other.Id, new CreateChapterRequest("Elsewhere")); + + Assert.That( + async () => await Arcs.CreateAsync( + _characterId, new CreateArcStageRequest("A stage", ChapterId: elsewhere.Id)), + Throws.TypeOf().With.Message.Contains("same project")); + } + + [Test] + public async Task Deleting_a_chapter_unpins_an_arc_stage_rather_than_deleting_it() + { + var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + var stage = await Arcs.CreateAsync( + _characterId, new CreateArcStageRequest("The map is wrong", ChapterId: chapter.Id)); + + await Chapters.DeleteAsync(chapter.Id); + + // How a character changes outlives a decision about where the chapter break falls. + var survivor = await Arcs.GetAsync(stage.Id); + + Assert.Multiple(() => + { + Assert.That(survivor.ChapterId, Is.Null); + Assert.That(survivor.Title, Is.EqualTo("The map is wrong")); + }); + } + + [Test] + public async Task Deleting_a_character_takes_their_arc() + { + await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("She trusts the map")); + + await Characters.DeleteAsync(_characterId); + + Assert.That(await Arcs.ListAsync(_characterId), Is.Empty); + } + + [Test] + public void Reordering_with_an_unknown_stage_is_refused() => + Assert.That( + async () => await Arcs.ReorderAsync( + _characterId, new ReorderArcStagesRequest([Guid.NewGuid()])), + Throws.TypeOf()); + + [Test] + public async Task The_character_page_sees_every_beat_they_appear_in_across_the_book() + { + var second = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second", Number: 2)); + var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("First", Number: 1)); + var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + + await Beats.CreateAsync(second.Id, new CreateBeatRequest("She boards anyway", CharacterId: _characterId)); + await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterId: _characterId)); + await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterId: mara.Id)); + await Beats.CreateAsync(first.Id, new CreateBeatRequest("Nobody's beat")); + + var beats = await Beats.ListForCharacterAsync(_characterId); + + Assert.Multiple(() => + { + Assert.That(beats.Select(b => b.Title), Is.EqualTo(new[] { "She finds the map", "She boards anyway" })); + Assert.That(beats[0].ChapterNumber, Is.EqualTo(1)); + Assert.That(beats[0].ChapterTitle, Is.EqualTo("First")); + Assert.That(beats[1].ChapterId, Is.EqualTo(second.Id)); + }); + } + + [Test] + public void Asking_for_the_beats_of_a_character_who_does_not_exist_reports_not_found() => + Assert.That( + async () => await Beats.ListForCharacterAsync(Guid.NewGuid()), + Throws.TypeOf()); +} diff --git a/tests/Novelly.Api.Tests/ListingTests.cs b/tests/Novelly.Api.Tests/ListingTests.cs index fbe1f9a..b103d0e 100644 --- a/tests/Novelly.Api.Tests/ListingTests.cs +++ b/tests/Novelly.Api.Tests/ListingTests.cs @@ -97,7 +97,7 @@ public class ListingTests : ServiceTestFixture var agent = new NovelAgentService( Db.Context, new ScriptedModelClient([[new AgentTextBlock("Reply.")]]), - new NovelAgentToolset(Projects, Characters, Chapters, Beats, Scenes, Tags), + new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions), Options.Create(new AgentOptions()), NullLogger.Instance); diff --git a/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs b/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs index 8a25673..1fd1be0 100644 --- a/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs +++ b/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs @@ -12,7 +12,7 @@ public class NovelAgentServiceTests : ServiceTestFixture private NovelAgentToolset _toolset = null!; protected override void OnSetUp() => - _toolset = new NovelAgentToolset(Projects, Characters, Chapters, Beats, Scenes, Tags); + _toolset = new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions); private NovelAgentService BuildAgent(ScriptedModelClient model) => new( Db.Context, diff --git a/tests/Novelly.Api.Tests/OpenQuestionTests.cs b/tests/Novelly.Api.Tests/OpenQuestionTests.cs new file mode 100644 index 0000000..8f09873 --- /dev/null +++ b/tests/Novelly.Api.Tests/OpenQuestionTests.cs @@ -0,0 +1,261 @@ +using Novelly.Api.Chapters; +using Novelly.Api.Characters; +using Novelly.Api.Common; +using Novelly.Api.Projects; +using Novelly.Api.Questions; + +namespace Novelly.Api.Tests; + +[TestFixture] +public class OpenQuestionTests : ServiceTestFixture +{ + private Guid _projectId; + private Guid _chapterId; + private Guid _characterId; + + protected override void OnSetUp() + { + _projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; + _chapterId = Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")).Result.Id; + _characterId = Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")).Result.Id; + } + + [Test] + public async Task A_question_can_hang_off_a_chapter_and_a_character_at_once() + { + var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "Does she know about the letter before the harbour?", + ChapterId: _chapterId, + CharacterId: _characterId)); + + Assert.Multiple(() => + { + Assert.That(question.ChapterTitle, Is.EqualTo("Landfall")); + Assert.That(question.ChapterNumber, Is.EqualTo(1)); + Assert.That(question.CharacterName, Is.EqualTo("Ines")); + Assert.That(question.IsResolved, Is.False); + }); + } + + [Test] + public async Task A_question_about_the_book_as_a_whole_needs_no_association() + { + var question = await Questions.CreateAsync( + _projectId, new CreateOpenQuestionRequest("Is this one book or two?")); + + Assert.Multiple(() => + { + Assert.That(question.ChapterId, Is.Null); + Assert.That(question.CharacterId, Is.Null); + }); + } + + [Test] + public async Task The_outline_and_the_character_page_each_see_only_their_own_questions() + { + await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "Where does the chapter break?", ChapterId: _chapterId)); + await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "What does she actually want?", CharacterId: _characterId)); + await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); + + var forChapter = await Questions.ListAsync(_projectId, chapterId: _chapterId); + var forCharacter = await Questions.ListAsync(_projectId, characterId: _characterId); + var forProject = await Questions.ListAsync(_projectId); + + Assert.Multiple(() => + { + Assert.That(forChapter.Select(q => q.Question), Is.EqualTo(new[] { "Where does the chapter break?" })); + Assert.That(forCharacter.Select(q => q.Question), Is.EqualTo(new[] { "What does she actually want?" })); + Assert.That(forProject, Has.Count.EqualTo(3)); + }); + } + + [Test] + public async Task Resolved_questions_drop_off_the_list_unless_asked_for() + { + var settled = await Questions.CreateAsync( + _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); + await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); + + await Questions.ResolveAsync(settled.Id, new ResolveOpenQuestionRequest("After the harbour.")); + + var open = await Questions.ListAsync(_projectId); + var everything = await Questions.ListAsync(_projectId, includeResolved: true); + + Assert.Multiple(() => + { + Assert.That(open.Select(q => q.Question), Is.EqualTo(new[] { "Is this one book or two?" })); + Assert.That(everything, Has.Count.EqualTo(2)); + + // Still-open questions come first, so the list stays about what is undecided. + Assert.That(everything[0].IsResolved, Is.False); + Assert.That(everything[1].IsResolved, Is.True); + }); + } + + [Test] + public async Task Resolving_records_what_was_decided() + { + var question = await Questions.CreateAsync( + _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); + + var resolved = await Questions.ResolveAsync( + question.Id, new ResolveOpenQuestionRequest("After the harbour burns.")); + + Assert.Multiple(() => + { + Assert.That(resolved.IsResolved, Is.True); + Assert.That(resolved.Resolution, Is.EqualTo("After the harbour burns.")); + Assert.That(resolved.ResolvedAt, Is.Not.Null); + }); + } + + [Test] + public async Task A_resolution_can_be_added_as_a_note_on_what_it_was_about() + { + await Chapters.UpdateAsync(_chapterId, new UpdateChapterRequest(Notes: "Runs long.")); + + var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId)); + + await Questions.ResolveAsync( + question.Id, + new ResolveOpenQuestionRequest("After the harbour burns.", AppendToNotes: true)); + + var chapter = await Chapters.GetAsync(_chapterId); + var character = await Characters.GetAsync(_characterId); + + Assert.Multiple(() => + { + // The existing note is kept and the decision lands underneath it. + Assert.That(chapter.Notes, Does.StartWith("Runs long.")); + Assert.That(chapter.Notes, Does.Contain("Where does the chapter break? — After the harbour burns.")); + Assert.That(character.Notes, Is.EqualTo("Where does the chapter break? — After the harbour burns.")); + }); + } + + [Test] + public async Task A_resolution_stays_off_the_notes_unless_asked_for() + { + var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "Where does the chapter break?", ChapterId: _chapterId)); + + await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest("After the harbour.")); + + Assert.That((await Chapters.GetAsync(_chapterId)).Notes, Is.Null); + } + + [Test] + public async Task Reopening_clears_the_resolution_but_leaves_the_note_behind() + { + var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "Where does the chapter break?", ChapterId: _chapterId)); + + await Questions.ResolveAsync( + question.Id, new ResolveOpenQuestionRequest("After the harbour.", AppendToNotes: true)); + + var reopened = await Questions.ReopenAsync(question.Id); + var chapter = await Chapters.GetAsync(_chapterId); + + Assert.Multiple(() => + { + Assert.That(reopened.IsResolved, Is.False); + Assert.That(reopened.Resolution, Is.Null); + Assert.That(chapter.Notes, Does.Contain("After the harbour.")); + }); + } + + [Test] + public async Task A_question_can_be_detached_from_what_it_was_about() + { + var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId)); + + var detached = await Questions.UpdateAsync( + question.Id, new UpdateOpenQuestionRequest(ClearChapter: true)); + + Assert.Multiple(() => + { + Assert.That(detached.ChapterId, Is.Null); + + // Only the chapter was cleared — a null id means "leave alone", not "detach". + Assert.That(detached.CharacterId, Is.EqualTo(_characterId)); + }); + } + + [Test] + public async Task Deleting_a_chapter_leaves_its_questions_open_rather_than_taking_them() + { + var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + "Does she know about the letter?", ChapterId: _chapterId)); + + await Chapters.DeleteAsync(_chapterId); + + var survivor = await Questions.GetAsync(question.Id); + + Assert.Multiple(() => + { + Assert.That(survivor.ChapterId, Is.Null); + Assert.That(survivor.Question, Is.EqualTo("Does she know about the letter?")); + }); + } + + [Test] + public async Task A_question_can_be_deleted_outright() + { + var question = await Questions.CreateAsync( + _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); + + await Questions.DeleteAsync(question.Id); + + Assert.Multiple(async () => + { + Assert.That(await Questions.ListAsync(_projectId, includeResolved: true), Is.Empty); + Assert.That( + async () => await Questions.GetAsync(question.Id), + Throws.TypeOf()); + }); + } + + [Test] + public async Task Deleting_a_project_takes_its_questions() + { + await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); + + await Projects.DeleteAsync(_projectId); + + using var verification = Db.CreateContext(); + Assert.That(verification.OpenQuestions.Count(), Is.EqualTo(0)); + } + + [Test] + public void A_question_cannot_be_attached_to_another_project_s_chapter() + { + var elsewhere = Chapters.CreateAsync( + Projects.CreateAsync(new CreateProjectRequest("Other Book")).Result.Id, + new CreateChapterRequest("Elsewhere")).Result; + + Assert.That( + async () => await Questions.CreateAsync( + _projectId, new CreateOpenQuestionRequest("A question", ChapterId: elsewhere.Id)), + Throws.TypeOf().With.Message.Contains("same project")); + } + + [Test] + public void A_blank_question_is_refused() => + Assert.That( + async () => await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(" ")), + Throws.TypeOf()); + + [Test] + public async Task Resolving_with_nothing_decided_is_refused() + { + var question = await Questions.CreateAsync( + _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); + + Assert.That( + async () => await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest(" ")), + Throws.TypeOf()); + } +} diff --git a/tests/Novelly.Api.Tests/ServiceTestFixture.cs b/tests/Novelly.Api.Tests/ServiceTestFixture.cs index 4e1c4cc..a56cae4 100644 --- a/tests/Novelly.Api.Tests/ServiceTestFixture.cs +++ b/tests/Novelly.Api.Tests/ServiceTestFixture.cs @@ -2,6 +2,7 @@ using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Projects; +using Novelly.Api.Questions; using Novelly.Api.Scenes; using Novelly.Api.Tags; @@ -25,6 +26,8 @@ public abstract class ServiceTestFixture protected ChapterService Chapters { get; private set; } = null!; protected SceneService Scenes { get; private set; } = null!; protected BeatService Beats { get; private set; } = null!; + protected CharacterArcService Arcs { get; private set; } = null!; + protected OpenQuestionService Questions { get; private set; } = null!; [SetUp] public void SetUpFixture() @@ -36,6 +39,8 @@ public abstract class ServiceTestFixture Chapters = new ChapterService(Db.Context, Tags); Scenes = new SceneService(Db.Context); Beats = new BeatService(Db.Context, Tags); + Arcs = new CharacterArcService(Db.Context); + Questions = new OpenQuestionService(Db.Context); OnSetUp(); }