Add main/supporting characters, character arcs and open questions

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
co-authored by Claude Opus 5
parent 96021c5fee
commit 0358667679
34 changed files with 2638 additions and 13 deletions
@@ -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<InvalidOperationException>().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<NotFoundException>());
[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<NotFoundException>());
}
+1 -1
View File
@@ -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<NovelAgentService>.Instance);
@@ -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,
@@ -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<NotFoundException>());
});
}
[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<InvalidOperationException>().With.Message.Contains("same project"));
}
[Test]
public void A_blank_question_is_refused() =>
Assert.That(
async () => await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(" ")),
Throws.TypeOf<ArgumentException>());
[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<ArgumentException>());
}
}
@@ -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();
}