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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user