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
+1 -1
View File
@@ -456,7 +456,7 @@ public class NovelAgentToolset(
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound(
beats.ListForCharacterAsync(characterId, ct),
list => list.Select(b => b.ToCharacterBeatResponse()),
list => list.Select(b => b.ToCharacterBeatResponse(characterId)),
"Character",
characterId);
});
+2
View File
@@ -19,6 +19,8 @@ public class Beat
public List<Character> Characters { get; set; } = [];
public List<CharacterArcStage> ArcStages { get; set; } = [];
public string? WhatHappened { get; set; }
public string? WhatsNext { get; set; }
+5 -3
View File
@@ -84,7 +84,8 @@ public record CharacterBeatResponse(
int SortOrder,
string Title,
string? WhatHappened,
string? WhatsNext);
string? WhatsNext,
Guid? ArcStageId);
public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds);
@@ -150,7 +151,7 @@ public static class BeatMapping
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
b.UpdatedAt);
public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b) => new(
public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b, Guid characterId) => new(
b.Id,
b.ChapterId,
b.Chapter?.Number ?? 0,
@@ -158,5 +159,6 @@ public static class BeatMapping
b.SortOrder,
b.Title,
b.WhatHappened,
b.WhatsNext);
b.WhatsNext,
b.ArcStages.FirstOrDefault(s => s.CharacterId == characterId)?.Id);
}
+1 -1
View File
@@ -46,7 +46,7 @@ public static class BeatEndpoints
app.MapGet("/api/characters/{characterId:guid}/beats", async (
Guid characterId, BeatService service, CancellationToken ct) =>
(await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse()).ToList().ToApiResult())
(await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse(characterId)).ToList().ToApiResult())
.WithTags("Beats")
.WithSummary("Every beat this character appears in, in manuscript order.");
+1
View File
@@ -68,6 +68,7 @@ public class BeatService(
var beats = await db.Beats
.Include(b => b.Chapter)
.Include(b => b.ArcStages)
.Where(b => b.Characters.Any(c => c.Id == characterId))
.ToListAsync(ct);
@@ -12,7 +12,8 @@ public class CharacterArcService(
ILogger<CharacterArcService> logger,
IModelValidator<CreateArcStageRequest> createValidator,
IModelValidator<UpdateArcStageRequest> updateValidator,
IModelValidator<ReorderArcStagesRequest> reorderValidator)
IModelValidator<ReorderArcStagesRequest> reorderValidator,
IModelValidator<SetArcStageBeatsRequest> setBeatsValidator)
{
public async Task<IReadOnlyList<CharacterArcStage>> ListAsync(Guid characterId, CancellationToken ct = default)
{
@@ -70,7 +71,7 @@ public class CharacterArcService(
CharacterId = characterId,
Title = request.Title,
SortOrder = request.SortOrder ?? await NextSortOrderAsync(characterId, ct),
Description = request.Description,
Result = request.Result,
ChapterId = request.ChapterId
};
@@ -107,7 +108,7 @@ public class CharacterArcService(
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.Result = Patch.Apply(stage.Result, request.Result);
stage.ChapterId = request.ChapterId ?? stage.ChapterId;
stage.UpdatedAt = DateTimeOffset.UtcNow;
@@ -171,6 +172,67 @@ public class CharacterArcService(
return await ListAsync(characterId, ct);
}
public async Task<CharacterArcStage?> SetBeatsAsync(
Guid stageId, SetArcStageBeatsRequest request, CancellationToken ct = default)
{
Guard.Default(stageId, nameof(stageId));
Guard.Null(request, nameof(request));
setBeatsValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Setting {Count} beats for arc stage {ArcStageId}", request.BeatIds.Count, stageId);
var stage = await FindAsync(stageId, ct);
if (stage is null)
{
return null;
}
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct);
if (character is null)
{
logger.LogError("Arc stage {ArcStageId} references character {CharacterId} which does not exist", stageId, stage.CharacterId);
return null;
}
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
var beats = await db.Beats
.Include(b => b.Characters)
.Include(b => b.ArcStages)
.Where(b => request.BeatIds.Contains(b.Id))
.ToListAsync(ct);
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
if (missing.Count > 0)
{
logger.LogWarning("Rejected arc stage beat assignment: arc stage {ArcStageId} referenced missing beat {BeatId}", stageId, missing[0]);
return null;
}
var unrelated = beats.Where(b => b.Characters.All(c => c.Id != stage.CharacterId)).ToList();
if (unrelated.Count > 0)
{
logger.LogWarning(
"Rejected arc stage beat assignment: beat {BeatId} does not include character {CharacterId}",
unrelated[0].Id, stage.CharacterId);
throw new InvalidOperationException("A beat can only be grouped into an arc stage for a character who appears in it.");
}
foreach (var beat in beats)
{
foreach (var sibling in beat.ArcStages.Where(s => s.CharacterId == stage.CharacterId && s.Id != stageId).ToList())
{
beat.ArcStages.Remove(sibling);
}
}
stage.Beats = beats;
stage.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(stageId, ct))!;
}
private async Task EnsureChapterIsInSameProjectAsync(
Character character, Guid? chapterId, CancellationToken ct)
{
@@ -212,7 +274,10 @@ public class CharacterArcService(
await access.RequireAsync(projectId, permission, ct);
}
private IQueryable<CharacterArcStage> Query() => db.CharacterArcStages.Include(s => s.Chapter);
private IQueryable<CharacterArcStage> Query() =>
db.CharacterArcStages
.Include(s => s.Chapter)
.Include(s => s.Beats).ThenInclude(b => b.Chapter);
private async Task<CharacterArcStage?> FindAsync(Guid id, CancellationToken ct)
{
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
namespace Novelly.Api.Characters;
@@ -15,11 +16,13 @@ public class CharacterArcStage
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public string? Result { get; set; }
public Guid? ChapterId { get; set; }
public Chapter? Chapter { get; init; }
public List<Beat> Beats { get; set; } = [];
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
@@ -33,5 +36,8 @@ public class CharacterArcStageEntityTypeConfiguration : IEntityTypeConfiguration
entity.HasOne(s => s.Chapter).WithMany()
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull);
entity.HasMany(s => s.Beats).WithMany(b => b.ArcStages)
.UsingEntity(join => join.ToTable("ArcStageBeats"));
}
}
@@ -1,3 +1,4 @@
using Novelly.Api.Beats;
using Novelly.Api.Common.Validation;
using Novelly.Api.Tags;
@@ -162,7 +163,8 @@ file static class CharacterValidation
public record CreateRelationshipRequest(
Guid RelatedCharacterId,
string RelationshipType,
string? Description = null);
string? Description = null,
string? ReciprocalRelationshipType = null);
public class CreateRelationshipRequestValidator : IModelValidator<CreateRelationshipRequest>
{
@@ -175,6 +177,7 @@ public class CreateRelationshipRequestValidator : IModelValidator<CreateRelation
result.AddRequiredTextErrors("RelationshipType", "Relationship Type", model.RelationshipType, 100);
result.AddOptionalTextErrors("Description", "Description", model.Description, 2000);
result.AddOptionalTextErrors("ReciprocalRelationshipType", "Reciprocal Relationship Type", model.ReciprocalRelationshipType, 100);
return result;
}
@@ -205,16 +208,17 @@ public record ArcStageResponse(
Guid CharacterId,
int SortOrder,
string Title,
string? Description,
string? Result,
Guid? ChapterId,
int? ChapterNumber,
string? ChapterTitle,
IReadOnlyList<CharacterBeatResponse> Beats,
DateTimeOffset UpdatedAt);
public record CreateArcStageRequest(
string Title,
int? SortOrder = null,
string? Description = null,
string? Result = null,
Guid? ChapterId = null);
public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequest>
@@ -224,7 +228,7 @@ public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequ
var result = new ValidationResult();
result.AddRequiredTextErrors("Title", "Title", model.Title, 200);
ArcStageValidation.OptionalFields(model.SortOrder, model.Description, result);
ArcStageValidation.OptionalFields(model.SortOrder, model.Result, result);
return result;
}
@@ -233,7 +237,7 @@ public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequ
public record UpdateArcStageRequest(
string? Title = null,
int? SortOrder = null,
string? Description = null,
string? Result = null,
Guid? ChapterId = null);
public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequest>
@@ -243,7 +247,7 @@ public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequ
var result = new ValidationResult();
result.AddUnclearableTextErrors("Title", "Title", model.Title, "an arc stage", 200);
ArcStageValidation.OptionalFields(model.SortOrder, model.Description, result);
ArcStageValidation.OptionalFields(model.SortOrder, model.Result, result);
return result;
}
@@ -251,13 +255,13 @@ public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequ
file static class ArcStageValidation
{
public static void OptionalFields(int? sortOrder, string? description, ValidationResult result)
public static void OptionalFields(int? sortOrder, string? result_, ValidationResult result)
{
if (sortOrder is < 0)
result.AddError("SortOrder", "'Sort Order' must be zero or greater.");
if (description is { Length: > 20000 })
result.AddError("Description", "'Description' must be 20,000 characters or fewer.");
if (result_ is { Length: > 20000 })
result.AddError("Result", "'Result' must be 20,000 characters or fewer.");
}
}
@@ -276,6 +280,21 @@ public class ReorderArcStagesRequestValidator : IModelValidator<ReorderArcStages
}
}
public record SetArcStageBeatsRequest(IReadOnlyList<Guid> BeatIds);
public class SetArcStageBeatsRequestValidator : IModelValidator<SetArcStageBeatsRequest>
{
public ValidationResult Validate(SetArcStageBeatsRequest model)
{
var result = new ValidationResult();
if (model.BeatIds is null)
result.AddError("BeatIds", "'Beat Ids' must not be null.");
return result;
}
}
public static class CharacterMapping
{
@@ -305,9 +324,13 @@ public static class CharacterMapping
s.CharacterId,
s.SortOrder,
s.Title,
s.Description,
s.Result,
s.ChapterId,
s.Chapter?.Number,
s.Chapter?.Title,
[.. s.Beats
.OrderBy(b => b.Chapter?.Number ?? 0)
.ThenBy(b => b.SortOrder)
.Select(b => b.ToCharacterBeatResponse(s.CharacterId))],
s.UpdatedAt);
}
@@ -107,6 +107,12 @@ public static class CharacterEndpoints
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete an arc stage.");
arcStages.MapPost("/{id:guid}/beats", async (
Guid id, SetArcStageBeatsRequest request, CharacterArcService service, CancellationToken ct) =>
(await service.SetBeatsAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Set which beats belong to this arc stage, replacing its current set. "
+ "A beat moved into this stage leaves any other stage of the same character it was in.");
return app;
}
}
@@ -214,6 +214,14 @@ public class CharacterService(
Description = request.Description
});
db.CharacterRelationships.Add(new CharacterRelationship
{
CharacterId = request.RelatedCharacterId,
RelatedCharacterId = characterId,
RelationshipType = request.ReciprocalRelationshipType ?? request.RelationshipType,
Description = request.Description
});
await db.SaveChangesAsync(ct);
return (await FindAsync(characterId, ct))!;
}
@@ -235,7 +243,12 @@ public class CharacterService(
await access.RequireAsync(relationship.Character!.ProjectId, ProjectPermission.Write, ct);
var reciprocals = await db.CharacterRelationships
.Where(r => r.CharacterId == relationship.RelatedCharacterId && r.RelatedCharacterId == relationship.CharacterId)
.ToListAsync(ct);
db.CharacterRelationships.Remove(relationship);
db.CharacterRelationships.RemoveRange(reciprocals);
await db.SaveChangesAsync(ct);
return true;
}
@@ -338,6 +351,9 @@ public class CharacterService(
.Include(c => c.Tags)
.Include(c => c.ArcStages)
.ThenInclude(s => s.Chapter)
.Include(c => c.ArcStages)
.ThenInclude(s => s.Beats)
.ThenInclude(b => b.Chapter)
.Include(c => c.SameCharacterAs)
.Include(c => c.OtherIdentities)
.Include(c => c.RevealedInChapter);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddArcStageResultAndBeats : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Description",
table: "CharacterArcStages",
newName: "Result");
migrationBuilder.CreateTable(
name: "ArcStageBeats",
columns: table => new
{
ArcStagesId = table.Column<Guid>(type: "TEXT", nullable: false),
BeatsId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ArcStageBeats", x => new { x.ArcStagesId, x.BeatsId });
table.ForeignKey(
name: "FK_ArcStageBeats_Beats_BeatsId",
column: x => x.BeatsId,
principalTable: "Beats",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ArcStageBeats_CharacterArcStages_ArcStagesId",
column: x => x.ArcStagesId,
principalTable: "CharacterArcStages",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ArcStageBeats_BeatsId",
table: "ArcStageBeats",
column: "BeatsId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ArcStageBeats");
migrationBuilder.RenameColumn(
name: "Result",
table: "CharacterArcStages",
newName: "Description");
}
}
}
@@ -32,6 +32,21 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("BeatCharacters", (string)null);
});
modelBuilder.Entity("BeatCharacterArcStage", b =>
{
b.Property<Guid>("ArcStagesId")
.HasColumnType("TEXT");
b.Property<Guid>("BeatsId")
.HasColumnType("TEXT");
b.HasKey("ArcStagesId", "BeatsId");
b.HasIndex("BeatsId");
b.ToTable("ArcStageBeats", (string)null);
});
modelBuilder.Entity("BeatTag", b =>
{
b.Property<Guid>("BeatsId")
@@ -398,7 +413,7 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Description")
b.Property<string>("Result")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
@@ -856,6 +871,21 @@ namespace Novelly.Api.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("BeatCharacterArcStage", b =>
{
b.HasOne("Novelly.Api.Characters.CharacterArcStage", null)
.WithMany()
.HasForeignKey("ArcStagesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Beats.Beat", null)
.WithMany()
.HasForeignKey("BeatsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("BeatTag", b =>
{
b.HasOne("Novelly.Api.Beats.Beat", null)
@@ -359,7 +359,7 @@ public class ImportAgentToolset(
characterId,
new CreateArcStageRequest(
JsonInput.RequiredString(input, "title"),
Description: JsonInput.String(input, "description"),
Result: JsonInput.String(input, "description"),
ChapterId: JsonInput.Guid(input, "chapter_id")), ct);
return created is null
+2 -4
View File
@@ -77,11 +77,9 @@ public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectReques
file static class ProjectValidation
{
public static void Title(string title, ValidationResult result) =>
result.AddRequiredTextErrors("Title", "Title", title, 200);
public static void Title(string title, ValidationResult result) => result.AddRequiredTextErrors("Title", "Title", title, 200);
public static void OptionalFields(
string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result)
public static void OptionalFields(string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result)
{
if (author is { Length: > 200 })
result.AddError("Author", "'Author' must be 200 characters or fewer.");
+3 -12
View File
@@ -42,10 +42,7 @@ public class ProjectService(
logger.LogInformation("Getting project {ProjectId}", id);
var project = await FindAsync(id, ct);
if (project is null)
{
return null;
}
if (project is null) return null;
await access.RequireAsync(id, ProjectPermission.Read, ct);
return project;
@@ -85,10 +82,7 @@ public class ProjectService(
logger.LogInformation("Updating project {ProjectId}", id);
var project = await FindAsync(id, ct);
if (project is null)
{
return null;
}
if (project is null) return null;
await access.RequireAsync(id, ProjectPermission.Write, ct);
@@ -113,10 +107,7 @@ public class ProjectService(
logger.LogInformation("Deleting project {ProjectId}", id);
var project = await FindAsync(id, ct);
if (project is null)
{
return false;
}
if (project is null) return false;
await access.RequireAsync(id, ProjectPermission.DeleteContent, ct);
+2 -2
View File
@@ -6,9 +6,9 @@ namespace Novelly.Api.Users;
public class NovellyUser : IdentityUser<Guid>
{
public string DisplayName { get; set; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public GlobalRole GlobalRole { get; set; } = GlobalRole.Reviewer;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
public class NovellyUserEntityTypeConfiguration : IEntityTypeConfiguration<NovellyUser>