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
@@ -101,6 +101,58 @@ public class CharacterServiceTests : ServiceTestFixture
});
}
[Test]
public async Task Adding_a_relationship_records_it_on_both_characters()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother"));
var inesAfter = (await Characters.GetAsync(ines.Id))!;
var maraAfter = (await Characters.GetAsync(mara.Id))!;
Assert.Multiple(() =>
{
Assert.That(inesAfter.Relationships, Has.Count.EqualTo(1));
Assert.That(inesAfter.Relationships[0].RelationshipType, Is.EqualTo("sister"));
Assert.That(inesAfter.Relationships[0].RelatedCharacterId, Is.EqualTo(mara.Id));
Assert.That(maraAfter.Relationships, Has.Count.EqualTo(1));
Assert.That(maraAfter.Relationships[0].RelationshipType, Is.EqualTo("brother"));
Assert.That(maraAfter.Relationships[0].RelatedCharacterId, Is.EqualTo(ines.Id));
});
}
[Test]
public async Task A_relationship_with_no_reciprocal_type_mirrors_the_same_type_both_ways()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
await Characters.AddRelationshipAsync(ines.Id, new CreateRelationshipRequest(mara.Id, "rival"));
var maraAfter = (await Characters.GetAsync(mara.Id))!;
Assert.That(maraAfter.Relationships[0].RelationshipType, Is.EqualTo("rival"));
}
[Test]
public async Task Removing_a_relationship_removes_the_reciprocal_side_too()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
var withRelationship = (await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother")))!;
var relationshipId = withRelationship.Relationships[0].Id;
await Characters.RemoveRelationshipAsync(relationshipId);
var maraAfter = (await Characters.GetAsync(mara.Id))!;
Assert.That(maraAfter.Relationships, Is.Empty);
}
[Test]
public async Task Deleting_a_character_detaches_it_from_beats_rather_than_deleting_them()
{