Group character beats into arc-stage sections; reciprocal relationship types

Arc stages now group the beats that establish or pay off that stage of a
character's arc (many-to-many via ArcStageBeats), and carry a Result field
(renamed from Description) describing what the stage results in for the
character. Assigning a beat to a stage moves it out of any other stage of
the same character. New endpoint POST /api/arc-stages/{id}/beats, MCP tool
set_arc_stage_beats, and frontend grouping UI in CharacterArc/CharacterBeats.

Also records a relationship's reciprocal type so both characters' dossiers
show the correct direction (e.g. "sister" / "brother") instead of mirroring
the same label.
This commit is contained in:
James Wampler
2026-08-17 22:45:07 -07:00
parent eb1efcf9f8
commit 17facba3b9
27 changed files with 1871 additions and 74 deletions
+69 -2
View File
@@ -98,14 +98,14 @@ public class CharacterArcTests : ServiceTestFixture
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."));
"She trusts the map", Result: "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"));
Assert.That(character.ArcStages[0].Result, Does.Contain("her mother drew it"));
});
}
@@ -211,4 +211,71 @@ public class CharacterArcTests : ServiceTestFixture
[Test]
public async Task Asking_for_the_beats_of_a_character_who_does_not_exist_returns_null_rather_than_throwing() =>
Assert.That(await Beats.ListForCharacterAsync(Guid.NewGuid()), Is.Null);
[Test]
public async Task An_arc_stage_groups_the_beats_assigned_to_it()
{
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall"));
var spoiled = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Snaps at the crew", CharacterIds: [_characterId]));
var humbled = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Learns to swab a deck", CharacterIds: [_characterId]));
var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble"));
var updated = await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([spoiled!.Id]));
Assert.That(updated!.Beats.Select(b => b.Id), Is.EqualTo(new[] { spoiled.Id }));
Assert.That(humbled, Is.Not.Null);
}
[Test]
public async Task Assigning_a_beat_to_a_stage_moves_it_out_of_the_characters_other_stage()
{
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall"));
var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Gets hurt", CharacterIds: [_characterId]));
var early = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble"));
var later = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Humbled"));
await Arcs.SetBeatsAsync(early.Id, new SetArcStageBeatsRequest([beat!.Id]));
await Arcs.SetBeatsAsync(later.Id, new SetArcStageBeatsRequest([beat.Id]));
var earlyAfter = (await Arcs.GetAsync(early.Id))!;
var laterAfter = (await Arcs.GetAsync(later.Id))!;
Assert.Multiple(() =>
{
Assert.That(earlyAfter.Beats, Is.Empty);
Assert.That(laterAfter.Beats.Select(b => b.Id), Is.EqualTo(new[] { beat.Id }));
});
}
[Test]
public async Task A_beat_can_only_be_grouped_into_a_stage_for_a_character_who_appears_in_it()
{
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Mara alone", CharacterIds: [mara.Id]));
var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble"));
Assert.That(
async () => await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([beat!.Id])),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("appears in it"));
}
[Test]
public async Task Setting_beats_on_an_unknown_stage_returns_null_rather_than_throwing() =>
Assert.That(
await Arcs.SetBeatsAsync(Guid.NewGuid(), new SetArcStageBeatsRequest([])),
Is.Null);
[Test]
public async Task Clearing_a_stages_beats_with_an_empty_list_ungroups_them()
{
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall"));
var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Snaps at the crew", CharacterIds: [_characterId]));
var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble"));
await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([beat!.Id]));
var cleared = await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([]));
Assert.That(cleared!.Beats, Is.Empty);
}
}