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
+3
View File
@@ -104,6 +104,9 @@ dotnet_style_qualification_for_property = false:suggestion
dotnet_style_qualification_for_method = false:warning dotnet_style_qualification_for_method = false:warning
dotnet_style_qualification_for_event = false:warning dotnet_style_qualification_for_event = false:warning
dotnet_diagnostic.CA1873.severity = silent
[*.cs] [*.cs]
csharp_using_directive_placement = outside_namespace:silent csharp_using_directive_placement = outside_namespace:silent
csharp_prefer_simple_using_statement = true:suggestion csharp_prefer_simple_using_statement = true:suggestion
+1 -1
View File
@@ -456,7 +456,7 @@ public class NovelAgentToolset(
var characterId = JsonInput.RequiredGuid(input, "character_id"); var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound( return await OrNotFound(
beats.ListForCharacterAsync(characterId, ct), beats.ListForCharacterAsync(characterId, ct),
list => list.Select(b => b.ToCharacterBeatResponse()), list => list.Select(b => b.ToCharacterBeatResponse(characterId)),
"Character", "Character",
characterId); characterId);
}); });
+2
View File
@@ -19,6 +19,8 @@ public class Beat
public List<Character> Characters { get; set; } = []; public List<Character> Characters { get; set; } = [];
public List<CharacterArcStage> ArcStages { get; set; } = [];
public string? WhatHappened { get; set; } public string? WhatHappened { get; set; }
public string? WhatsNext { get; set; } public string? WhatsNext { get; set; }
+5 -3
View File
@@ -84,7 +84,8 @@ public record CharacterBeatResponse(
int SortOrder, int SortOrder,
string Title, string Title,
string? WhatHappened, string? WhatHappened,
string? WhatsNext); string? WhatsNext,
Guid? ArcStageId);
public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds); 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.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
b.UpdatedAt); b.UpdatedAt);
public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b) => new( public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b, Guid characterId) => new(
b.Id, b.Id,
b.ChapterId, b.ChapterId,
b.Chapter?.Number ?? 0, b.Chapter?.Number ?? 0,
@@ -158,5 +159,6 @@ public static class BeatMapping
b.SortOrder, b.SortOrder,
b.Title, b.Title,
b.WhatHappened, 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 ( app.MapGet("/api/characters/{characterId:guid}/beats", async (
Guid characterId, BeatService service, CancellationToken ct) => 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") .WithTags("Beats")
.WithSummary("Every beat this character appears in, in manuscript order."); .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 var beats = await db.Beats
.Include(b => b.Chapter) .Include(b => b.Chapter)
.Include(b => b.ArcStages)
.Where(b => b.Characters.Any(c => c.Id == characterId)) .Where(b => b.Characters.Any(c => c.Id == characterId))
.ToListAsync(ct); .ToListAsync(ct);
@@ -12,7 +12,8 @@ public class CharacterArcService(
ILogger<CharacterArcService> logger, ILogger<CharacterArcService> logger,
IModelValidator<CreateArcStageRequest> createValidator, IModelValidator<CreateArcStageRequest> createValidator,
IModelValidator<UpdateArcStageRequest> updateValidator, IModelValidator<UpdateArcStageRequest> updateValidator,
IModelValidator<ReorderArcStagesRequest> reorderValidator) IModelValidator<ReorderArcStagesRequest> reorderValidator,
IModelValidator<SetArcStageBeatsRequest> setBeatsValidator)
{ {
public async Task<IReadOnlyList<CharacterArcStage>> ListAsync(Guid characterId, CancellationToken ct = default) public async Task<IReadOnlyList<CharacterArcStage>> ListAsync(Guid characterId, CancellationToken ct = default)
{ {
@@ -70,7 +71,7 @@ public class CharacterArcService(
CharacterId = characterId, CharacterId = characterId,
Title = request.Title, Title = request.Title,
SortOrder = request.SortOrder ?? await NextSortOrderAsync(characterId, ct), SortOrder = request.SortOrder ?? await NextSortOrderAsync(characterId, ct),
Description = request.Description, Result = request.Result,
ChapterId = request.ChapterId ChapterId = request.ChapterId
}; };
@@ -107,7 +108,7 @@ public class CharacterArcService(
stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title; stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title;
stage.SortOrder = request.SortOrder ?? stage.SortOrder; 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.ChapterId = request.ChapterId ?? stage.ChapterId;
stage.UpdatedAt = DateTimeOffset.UtcNow; stage.UpdatedAt = DateTimeOffset.UtcNow;
@@ -171,6 +172,67 @@ public class CharacterArcService(
return await ListAsync(characterId, ct); 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( private async Task EnsureChapterIsInSameProjectAsync(
Character character, Guid? chapterId, CancellationToken ct) Character character, Guid? chapterId, CancellationToken ct)
{ {
@@ -212,7 +274,10 @@ public class CharacterArcService(
await access.RequireAsync(projectId, permission, ct); 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) private async Task<CharacterArcStage?> FindAsync(Guid id, CancellationToken ct)
{ {
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
namespace Novelly.Api.Characters; namespace Novelly.Api.Characters;
@@ -15,11 +16,13 @@ public class CharacterArcStage
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string? Description { get; set; } public string? Result { get; set; }
public Guid? ChapterId { get; set; } public Guid? ChapterId { get; set; }
public Chapter? Chapter { get; init; } public Chapter? Chapter { get; init; }
public List<Beat> Beats { get; set; } = [];
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
} }
@@ -33,5 +36,8 @@ public class CharacterArcStageEntityTypeConfiguration : IEntityTypeConfiguration
entity.HasOne(s => s.Chapter).WithMany() entity.HasOne(s => s.Chapter).WithMany()
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull); .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.Common.Validation;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -162,7 +163,8 @@ file static class CharacterValidation
public record CreateRelationshipRequest( public record CreateRelationshipRequest(
Guid RelatedCharacterId, Guid RelatedCharacterId,
string RelationshipType, string RelationshipType,
string? Description = null); string? Description = null,
string? ReciprocalRelationshipType = null);
public class CreateRelationshipRequestValidator : IModelValidator<CreateRelationshipRequest> public class CreateRelationshipRequestValidator : IModelValidator<CreateRelationshipRequest>
{ {
@@ -175,6 +177,7 @@ public class CreateRelationshipRequestValidator : IModelValidator<CreateRelation
result.AddRequiredTextErrors("RelationshipType", "Relationship Type", model.RelationshipType, 100); result.AddRequiredTextErrors("RelationshipType", "Relationship Type", model.RelationshipType, 100);
result.AddOptionalTextErrors("Description", "Description", model.Description, 2000); result.AddOptionalTextErrors("Description", "Description", model.Description, 2000);
result.AddOptionalTextErrors("ReciprocalRelationshipType", "Reciprocal Relationship Type", model.ReciprocalRelationshipType, 100);
return result; return result;
} }
@@ -205,16 +208,17 @@ public record ArcStageResponse(
Guid CharacterId, Guid CharacterId,
int SortOrder, int SortOrder,
string Title, string Title,
string? Description, string? Result,
Guid? ChapterId, Guid? ChapterId,
int? ChapterNumber, int? ChapterNumber,
string? ChapterTitle, string? ChapterTitle,
IReadOnlyList<CharacterBeatResponse> Beats,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
public record CreateArcStageRequest( public record CreateArcStageRequest(
string Title, string Title,
int? SortOrder = null, int? SortOrder = null,
string? Description = null, string? Result = null,
Guid? ChapterId = null); Guid? ChapterId = null);
public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequest> public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequest>
@@ -224,7 +228,7 @@ public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequ
var result = new ValidationResult(); var result = new ValidationResult();
result.AddRequiredTextErrors("Title", "Title", model.Title, 200); result.AddRequiredTextErrors("Title", "Title", model.Title, 200);
ArcStageValidation.OptionalFields(model.SortOrder, model.Description, result); ArcStageValidation.OptionalFields(model.SortOrder, model.Result, result);
return result; return result;
} }
@@ -233,7 +237,7 @@ public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequ
public record UpdateArcStageRequest( public record UpdateArcStageRequest(
string? Title = null, string? Title = null,
int? SortOrder = null, int? SortOrder = null,
string? Description = null, string? Result = null,
Guid? ChapterId = null); Guid? ChapterId = null);
public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequest> public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequest>
@@ -243,7 +247,7 @@ public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequ
var result = new ValidationResult(); var result = new ValidationResult();
result.AddUnclearableTextErrors("Title", "Title", model.Title, "an arc stage", 200); 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; return result;
} }
@@ -251,13 +255,13 @@ public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequ
file static class ArcStageValidation 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) if (sortOrder is < 0)
result.AddError("SortOrder", "'Sort Order' must be zero or greater."); result.AddError("SortOrder", "'Sort Order' must be zero or greater.");
if (description is { Length: > 20000 }) if (result_ is { Length: > 20000 })
result.AddError("Description", "'Description' must be 20,000 characters or fewer."); 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 public static class CharacterMapping
{ {
@@ -305,9 +324,13 @@ public static class CharacterMapping
s.CharacterId, s.CharacterId,
s.SortOrder, s.SortOrder,
s.Title, s.Title,
s.Description, s.Result,
s.ChapterId, s.ChapterId,
s.Chapter?.Number, s.Chapter?.Number,
s.Chapter?.Title, s.Chapter?.Title,
[.. s.Beats
.OrderBy(b => b.Chapter?.Number ?? 0)
.ThenBy(b => b.SortOrder)
.Select(b => b.ToCharacterBeatResponse(s.CharacterId))],
s.UpdatedAt); s.UpdatedAt);
} }
@@ -107,6 +107,12 @@ public static class CharacterEndpoints
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()) await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete an arc stage."); .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; return app;
} }
} }
@@ -214,6 +214,14 @@ public class CharacterService(
Description = request.Description 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); await db.SaveChangesAsync(ct);
return (await FindAsync(characterId, ct))!; return (await FindAsync(characterId, ct))!;
} }
@@ -235,7 +243,12 @@ public class CharacterService(
await access.RequireAsync(relationship.Character!.ProjectId, ProjectPermission.Write, ct); 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.Remove(relationship);
db.CharacterRelationships.RemoveRange(reciprocals);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true; return true;
} }
@@ -338,6 +351,9 @@ public class CharacterService(
.Include(c => c.Tags) .Include(c => c.Tags)
.Include(c => c.ArcStages) .Include(c => c.ArcStages)
.ThenInclude(s => s.Chapter) .ThenInclude(s => s.Chapter)
.Include(c => c.ArcStages)
.ThenInclude(s => s.Beats)
.ThenInclude(b => b.Chapter)
.Include(c => c.SameCharacterAs) .Include(c => c.SameCharacterAs)
.Include(c => c.OtherIdentities) .Include(c => c.OtherIdentities)
.Include(c => c.RevealedInChapter); .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); 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 => modelBuilder.Entity("BeatTag", b =>
{ {
b.Property<Guid>("BeatsId") b.Property<Guid>("BeatsId")
@@ -398,7 +413,7 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("Description") b.Property<string>("Result")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("SortOrder") b.Property<int>("SortOrder")
@@ -856,6 +871,21 @@ namespace Novelly.Api.Data.Migrations
.IsRequired(); .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 => modelBuilder.Entity("BeatTag", b =>
{ {
b.HasOne("Novelly.Api.Beats.Beat", null) b.HasOne("Novelly.Api.Beats.Beat", null)
@@ -359,7 +359,7 @@ public class ImportAgentToolset(
characterId, characterId,
new CreateArcStageRequest( new CreateArcStageRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
Description: JsonInput.String(input, "description"), Result: JsonInput.String(input, "description"),
ChapterId: JsonInput.Guid(input, "chapter_id")), ct); ChapterId: JsonInput.Guid(input, "chapter_id")), ct);
return created is null return created is null
+2 -4
View File
@@ -77,11 +77,9 @@ public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectReques
file static class ProjectValidation file static class ProjectValidation
{ {
public static void Title(string title, ValidationResult result) => public static void Title(string title, ValidationResult result) => result.AddRequiredTextErrors("Title", "Title", title, 200);
result.AddRequiredTextErrors("Title", "Title", title, 200);
public static void OptionalFields( public static void OptionalFields(string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result)
string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result)
{ {
if (author is { Length: > 200 }) if (author is { Length: > 200 })
result.AddError("Author", "'Author' must be 200 characters or fewer."); 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); logger.LogInformation("Getting project {ProjectId}", id);
var project = await FindAsync(id, ct); var project = await FindAsync(id, ct);
if (project is null) if (project is null) return null;
{
return null;
}
await access.RequireAsync(id, ProjectPermission.Read, ct); await access.RequireAsync(id, ProjectPermission.Read, ct);
return project; return project;
@@ -85,10 +82,7 @@ public class ProjectService(
logger.LogInformation("Updating project {ProjectId}", id); logger.LogInformation("Updating project {ProjectId}", id);
var project = await FindAsync(id, ct); var project = await FindAsync(id, ct);
if (project is null) if (project is null) return null;
{
return null;
}
await access.RequireAsync(id, ProjectPermission.Write, ct); await access.RequireAsync(id, ProjectPermission.Write, ct);
@@ -113,10 +107,7 @@ public class ProjectService(
logger.LogInformation("Deleting project {ProjectId}", id); logger.LogInformation("Deleting project {ProjectId}", id);
var project = await FindAsync(id, ct); var project = await FindAsync(id, ct);
if (project is null) if (project is null) return false;
{
return false;
}
await access.RequireAsync(id, ProjectPermission.DeleteContent, ct); 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 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 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> public class NovellyUserEntityTypeConfiguration : IEntityTypeConfiguration<NovellyUser>
+24 -7
View File
@@ -147,11 +147,11 @@ public static class CharacterTools
[Description("Id of the character whose arc to add to.")] Guid characterId, [Description("Id of the character whose arc to add to.")] Guid characterId,
[Description("A short handle for the change, three to five words.")] string title, [Description("A short handle for the change, three to five words.")] string title,
CancellationToken ct, CancellationToken ct,
[Description("What shifts in the character here, and what it costs them.")] string? description = null, [Description("What this stage of the arc results in for the character — what shifts, and what it costs them.")] string? result = null,
[Description("Id of the chapter where this stage lands, if it is pinned to one.")] Guid? chapterId = null, [Description("Id of the chapter where this stage lands, if it is pinned to one.")] Guid? chapterId = null,
[Description("Position in the arc. Appended to the end when omitted.")] int? sortOrder = null) => [Description("Position in the arc. Appended to the end when omitted.")] int? sortOrder = null) =>
api.PostAsync($"/api/characters/{characterId}/arc", api.PostAsync($"/api/characters/{characterId}/arc",
new { title, sortOrder, description, chapterId }, ct); new { title, sortOrder, result, chapterId }, ct);
[McpServerTool(Name = "update_arc_stage")] [McpServerTool(Name = "update_arc_stage")]
[Description("Revise a stage of a character's arc. Only the fields you supply change.")] [Description("Revise a stage of a character's arc. Only the fields you supply change.")]
@@ -160,11 +160,11 @@ public static class CharacterTools
[Description("The arc stage's id.")] Guid arcStageId, [Description("The arc stage's id.")] Guid arcStageId,
CancellationToken ct, CancellationToken ct,
[Description("New title for the stage.")] string? title = null, [Description("New title for the stage.")] string? title = null,
[Description("What shifts in the character here.")] string? description = null, [Description("What this stage of the arc results in for the character.")] string? result = null,
[Description("Id of the chapter where this stage lands.")] Guid? chapterId = null, [Description("Id of the chapter where this stage lands.")] Guid? chapterId = null,
[Description("Position in the arc.")] int? sortOrder = null) => [Description("Position in the arc.")] int? sortOrder = null) =>
api.PatchAsync($"/api/arc-stages/{arcStageId}", api.PatchAsync($"/api/arc-stages/{arcStageId}",
new { title, sortOrder, description, chapterId }, ct); new { title, sortOrder, result, chapterId }, ct);
[McpServerTool(Name = "delete_arc_stage")] [McpServerTool(Name = "delete_arc_stage")]
[Description("Remove a stage from a character's arc.")] [Description("Remove a stage from a character's arc.")]
@@ -184,17 +184,34 @@ public static class CharacterTools
CancellationToken ct) => CancellationToken ct) =>
api.PostAsync($"/api/characters/{characterId}/arc/reorder", new { stageIds }, ct); api.PostAsync($"/api/characters/{characterId}/arc/reorder", new { stageIds }, ct);
[McpServerTool(Name = "set_arc_stage_beats")]
[Description("Set which beats belong to an arc stage, replacing its current set. This groups the "
+ "chapter-level beats that establish or pay off this stage of the character's arc. A "
+ "beat moved into this stage leaves any other stage of the same character it was in. "
+ "Each beat must already include this character.")]
public static Task<CallToolResult> SetArcStageBeats(
NovelApiClient api,
[Description("The arc stage's id.")] Guid arcStageId,
[Description("Beat ids that belong to this stage, replacing whatever was there before.")] string[] beatIds,
CancellationToken ct) =>
api.PostAsync($"/api/arc-stages/{arcStageId}/beats", new { beatIds }, ct);
[McpServerTool(Name = "relate_characters")] [McpServerTool(Name = "relate_characters")]
[Description("Record a relationship from one character to another in the same project.")] [Description("Record a relationship between two characters in the same project. Creates both directions "
+ "at once — characterId's side and relatedCharacterId's side — so the pair always shows up "
+ "on both dossiers.")]
public static Task<CallToolResult> RelateCharacters( public static Task<CallToolResult> RelateCharacters(
NovelApiClient api, NovelApiClient api,
[Description("Id of the character the relationship belongs to.")] Guid characterId, [Description("Id of the character the relationship belongs to.")] Guid characterId,
[Description("Id of the character they are related to.")] Guid relatedCharacterId, [Description("Id of the character they are related to.")] Guid relatedCharacterId,
[Description("How they are related, e.g. 'sister', 'rival', 'former mentor'.")] string relationshipType, [Description("How characterId is related to relatedCharacterId, e.g. 'sister', 'rival', 'former mentor'.")] string relationshipType,
CancellationToken ct, CancellationToken ct,
[Description("How relatedCharacterId is related back to characterId, if different — e.g. 'brother' for "
+ "'sister'. Defaults to relationshipType when the relation is symmetric, like 'rival'.")]
string? reciprocalRelationshipType = null,
[Description("What the relationship is like, and where it is headed.")] string? description = null) => [Description("What the relationship is like, and where it is headed.")] string? description = null) =>
api.PostAsync($"/api/characters/{characterId}/relationships", api.PostAsync($"/api/characters/{characterId}/relationships",
new { relatedCharacterId, relationshipType, description }, ct); new { relatedCharacterId, relationshipType, reciprocalRelationshipType, description }, ct);
[McpServerTool(Name = "link_character_identity")] [McpServerTool(Name = "link_character_identity")]
[Description("Record that this character is really another character — e.g. a character introduced " [Description("Record that this character is really another character — e.g. a character introduced "
+48 -2
View File
@@ -204,6 +204,40 @@ export function useUnlinkCharacterIdentity(projectId: string) {
}) })
} }
export function useAddRelationship(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
id,
relatedCharacterId,
relationshipType,
reciprocalRelationshipType,
description,
}: {
id: string
relatedCharacterId: string
relationshipType: string
reciprocalRelationshipType?: string | null
description?: string | null
}) =>
api.post<Character>(`/api/characters/${id}/relationships`, {
relatedCharacterId,
relationshipType,
reciprocalRelationshipType,
description,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
})
}
export function useRemoveRelationship(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (relationshipId: string) => api.delete(`/api/characters/relationships/${relationshipId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
})
}
export const useCharacterBeats = (characterId: string | undefined) => export const useCharacterBeats = (characterId: string | undefined) =>
useQuery({ useQuery({
queryKey: keys.characterBeats(characterId ?? ''), queryKey: keys.characterBeats(characterId ?? ''),
@@ -214,7 +248,7 @@ export const useCharacterBeats = (characterId: string | undefined) =>
export function useCreateArcStage(projectId: string) { export function useCreateArcStage(projectId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ characterId, ...body }: { characterId: string; title: string; description?: string; chapterId?: string }) => mutationFn: ({ characterId, ...body }: { characterId: string; title: string; result?: string; chapterId?: string }) =>
api.post<ArcStage>(`/api/characters/${characterId}/arc`, body), api.post<ArcStage>(`/api/characters/${characterId}/arc`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
}) })
@@ -223,12 +257,24 @@ export function useCreateArcStage(projectId: string) {
export function useUpdateArcStage(projectId: string) { export function useUpdateArcStage(projectId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: { id: string; title?: string; description?: string; chapterId?: string }) => mutationFn: ({ id, ...body }: { id: string; title?: string; result?: string; chapterId?: string }) =>
api.patch<ArcStage>(`/api/arc-stages/${id}`, body), api.patch<ArcStage>(`/api/arc-stages/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
}) })
} }
export function useSetArcStageBeats(projectId: string, characterId: string | undefined) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, beatIds }: { id: string; beatIds: string[] }) =>
api.post<ArcStage>(`/api/arc-stages/${id}/beats`, { beatIds }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
qc.invalidateQueries({ queryKey: keys.characterBeats(characterId ?? '') })
},
})
}
export function useDeleteArcStage(projectId: string) { export function useDeleteArcStage(projectId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
+3 -1
View File
@@ -151,10 +151,11 @@ export interface ArcStage {
characterId: string characterId: string
sortOrder: number sortOrder: number
title: string title: string
description: string | null result: string | null
chapterId: string | null chapterId: string | null
chapterNumber: number | null chapterNumber: number | null
chapterTitle: string | null chapterTitle: string | null
beats: CharacterBeat[]
updatedAt: string updatedAt: string
} }
@@ -167,6 +168,7 @@ export interface CharacterBeat {
title: string title: string
whatHappened: string | null whatHappened: string | null
whatsNext: string | null whatsNext: string | null
arcStageId: string | null
} }
export interface Character { export interface Character {
@@ -1,10 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
useCharacterBeats,
useChapters, useChapters,
useCreateArcStage, useCreateArcStage,
useDeleteArcStage, useDeleteArcStage,
useReorderArcStages, useReorderArcStages,
useSetArcStageBeats,
useUpdateArcStage, useUpdateArcStage,
} from '../api/hooks' } from '../api/hooks'
import type { ArcStage, Character } from '../api/types' import type { ArcStage, Character } from '../api/types'
@@ -24,12 +26,14 @@ export function CharacterArc({
canDelete: boolean canDelete: boolean
}) { }) {
const { data: chapters } = useChapters(projectId) const { data: chapters } = useChapters(projectId)
const { data: beats } = useCharacterBeats(character.id)
const create = useCreateArcStage(projectId) const create = useCreateArcStage(projectId)
const reorder = useReorderArcStages(projectId) const reorder = useReorderArcStages(projectId)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const stages = character.arcStages const stages = character.arcStages
const unassignedBeats = (beats ?? []).filter((b) => b.arcStageId === null)
const submit = (e: React.FormEvent) => { const submit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -56,8 +60,9 @@ export function CharacterArc({
)} )}
</div> </div>
<p className="mb-3 text-xs muted"> <p className="mb-3 text-xs muted">
The changes {character.name} goes through, in order. Pin a stage to the chapter it The sections {character.name}&rsquo;s arc breaks into, in order each one a short span of
lands in and it links into that outline. beats and what it results in for them. Pin a section to the chapter it lands in and it
links into that outline.
</p> </p>
{stages.length > 0 && ( {stages.length > 0 && (
@@ -68,6 +73,7 @@ export function CharacterArc({
projectId={projectId} projectId={projectId}
stage={stage} stage={stage}
chapters={chapters ?? []} chapters={chapters ?? []}
unassignedBeats={unassignedBeats}
canMoveUp={index > 0} canMoveUp={index > 0}
canMoveDown={index < stages.length - 1} canMoveDown={index < stages.length - 1}
onMove={(delta) => move(index, delta)} onMove={(delta) => move(index, delta)}
@@ -78,11 +84,18 @@ export function CharacterArc({
</ol> </ol>
)} )}
{unassignedBeats.length > 0 && (
<p className="mt-3 text-xs muted">
{unassignedBeats.length} beat{unassignedBeats.length === 1 ? '' : 's'} not yet grouped
into a section add {character.name} to a section above, or check the Beats list below.
</p>
)}
{canCreate && ( {canCreate && (
<form onSubmit={submit} className="mt-3 flex gap-2"> <form onSubmit={submit} className="mt-3 flex gap-2">
<input <input
className="input flex-1" className="input flex-1"
placeholder="Add a stage — three to five words, e.g. “she stops covering for him”" placeholder="Add a section — a short title, e.g. “spoiled noble”"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
@@ -105,6 +118,7 @@ function ArcStageRow({
projectId, projectId,
stage, stage,
chapters, chapters,
unassignedBeats,
canMoveUp, canMoveUp,
canMoveDown, canMoveDown,
onMove, onMove,
@@ -114,6 +128,7 @@ function ArcStageRow({
projectId: string projectId: string
stage: ArcStage stage: ArcStage
chapters: { id: string; number: number; title: string }[] chapters: { id: string; number: number; title: string }[]
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
canMoveUp: boolean canMoveUp: boolean
canMoveDown: boolean canMoveDown: boolean
onMove: (delta: number) => void onMove: (delta: number) => void
@@ -122,6 +137,16 @@ function ArcStageRow({
}) { }) {
const update = useUpdateArcStage(projectId) const update = useUpdateArcStage(projectId)
const remove = useDeleteArcStage(projectId) const remove = useDeleteArcStage(projectId)
const setBeats = useSetArcStageBeats(projectId, stage.characterId)
const addBeat = (beatId: string) => {
if (!beatId) return
setBeats.mutate({ id: stage.id, beatIds: [...stage.beats.map((b) => b.id), beatId] })
}
const removeBeat = (beatId: string) => {
setBeats.mutate({ id: stage.id, beatIds: stage.beats.filter((b) => b.id !== beatId).map((b) => b.id) })
}
return ( return (
<li <li
@@ -138,14 +163,59 @@ function ArcStageRow({
readOnly={!canWrite} readOnly={!canWrite}
/> />
<AutoField <AutoField
value={stage.description} value={stage.result}
multiline multiline
rows={2} rows={2}
placeholder="What shifts here, and what it costs them." placeholder="What this results in for them — what shifts, and what it costs."
onCommit={(description) => update.mutate({ id: stage.id, description })} onCommit={(result) => update.mutate({ id: stage.id, result })}
readOnly={!canWrite} readOnly={!canWrite}
/> />
{stage.beats.length > 0 && (
<ul className="grid gap-1">
{stage.beats.map((beat) => (
<li
key={beat.id}
className="flex items-center gap-2 rounded px-2 py-1 text-xs"
style={{ background: 'var(--surface-1, rgba(0,0,0,0.015))' }}
>
<Link
className="shrink-0 tabular-nums underline"
style={{ color: 'var(--accent)' }}
to={`/projects/${projectId}/chapters/${beat.chapterId}#beat-${beat.id}`}
>
{beat.chapterNumber}.{beat.sortOrder}
</Link>
<span className="min-w-0 flex-1 truncate">{beat.title}</span>
{canWrite && (
<button
className="btn shrink-0 px-1.5 py-0 text-xs"
onClick={() => removeBeat(beat.id)}
aria-label={`Remove beat ${beat.title} from this section`}
>
</button>
)}
</li>
))}
</ul>
)}
{canWrite && unassignedBeats.length > 0 && (
<select
className="input py-1 text-xs"
value=""
onChange={(e) => addBeat(e.target.value)}
>
<option value="">Add a beat to this section</option>
{unassignedBeats.map((beat) => (
<option key={beat.id} value={beat.id}>
{beat.chapterNumber}.{beat.sortOrder} {beat.title}
</option>
))}
</select>
)}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<select <select
className="input max-w-[16rem] py-1 text-xs" className="input max-w-[16rem] py-1 text-xs"
@@ -1,17 +1,21 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useCharacterBeats } from '../api/hooks' import { useCharacterBeats } from '../api/hooks'
import type { ArcStage } from '../api/types'
import { ErrorNote, Spinner } from './ui' import { ErrorNote, Spinner } from './ui'
export function CharacterBeats({ export function CharacterBeats({
projectId, projectId,
characterId, characterId,
characterName, characterName,
arcStages,
}: { }: {
projectId: string projectId: string
characterId: string characterId: string
characterName: string characterName: string
arcStages: ArcStage[]
}) { }) {
const { data: beats, isPending, error } = useCharacterBeats(characterId) const { data: beats, isPending, error } = useCharacterBeats(characterId)
const stageTitleById = new Map(arcStages.map((s) => [s.id, s.title]))
return ( return (
<section className="card mt-6 p-5"> <section className="card mt-6 p-5">
@@ -33,6 +37,7 @@ export function CharacterBeats({
<tr className="text-left text-xs uppercase muted"> <tr className="text-left text-xs uppercase muted">
<th className="py-1 pr-3 font-semibold">Chapter</th> <th className="py-1 pr-3 font-semibold">Chapter</th>
<th className="py-1 pr-3 font-semibold">Beat</th> <th className="py-1 pr-3 font-semibold">Beat</th>
<th className="py-1 pr-3 font-semibold">Arc section</th>
<th className="py-1 pr-3 font-semibold">What happened</th> <th className="py-1 pr-3 font-semibold">What happened</th>
<th className="py-1 font-semibold">What&rsquo;s next</th> <th className="py-1 font-semibold">What&rsquo;s next</th>
</tr> </tr>
@@ -51,6 +56,9 @@ export function CharacterBeats({
<div className="text-xs muted">{beat.chapterTitle}</div> <div className="text-xs muted">{beat.chapterTitle}</div>
</td> </td>
<td className="py-2 pr-3 font-medium">{beat.title}</td> <td className="py-2 pr-3 font-medium">{beat.title}</td>
<td className="py-2 pr-3 muted">
{beat.arcStageId ? (stageTitleById.get(beat.arcStageId) ?? '—') : '—'}
</td>
<td className="py-2 pr-3 muted">{beat.whatHappened}</td> <td className="py-2 pr-3 muted">{beat.whatHappened}</td>
<td className="py-2 muted">{beat.whatsNext}</td> <td className="py-2 muted">{beat.whatsNext}</td>
</tr> </tr>
+176 -15
View File
@@ -1,11 +1,13 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { import {
useAddRelationship,
useChapters, useChapters,
useCharacters, useCharacters,
useDeleteCharacter, useDeleteCharacter,
useLinkCharacterIdentity, useLinkCharacterIdentity,
useProject, useProject,
useRemoveRelationship,
useTags, useTags,
useUnlinkCharacterIdentity, useUnlinkCharacterIdentity,
useUpdateCharacter, useUpdateCharacter,
@@ -84,6 +86,8 @@ function CharacterSheet({
const remove = useDeleteCharacter(projectId) const remove = useDeleteCharacter(projectId)
const linkIdentity = useLinkCharacterIdentity(projectId) const linkIdentity = useLinkCharacterIdentity(projectId)
const unlinkIdentity = useUnlinkCharacterIdentity(projectId) const unlinkIdentity = useUnlinkCharacterIdentity(projectId)
const addRelationship = useAddRelationship(projectId)
const removeRelationship = useRemoveRelationship(projectId)
const [confirmingDelete, setConfirmingDelete] = useState(false) const [confirmingDelete, setConfirmingDelete] = useState(false)
const patch = (body: Partial<Omit<Character, 'tags' | 'aliases'>> & { tags?: string[]; aliases?: string[] }) => const patch = (body: Partial<Omit<Character, 'tags' | 'aliases'>> & { tags?: string[]; aliases?: string[] }) =>
update.mutate({ id: character.id, ...body }) update.mutate({ id: character.id, ...body })
@@ -233,21 +237,6 @@ function CharacterSheet({
/> />
</div> </div>
{character.relationships.length > 0 && (
<div className="mt-6">
<h3 className="label">Relationships</h3>
<ul className="grid gap-1 text-sm">
{character.relationships.map((relationship) => (
<li key={relationship.id}>
<span className="font-medium">{relationship.relatedCharacterName}</span>
<span className="muted"> {relationship.relationshipType}</span>
{relationship.description && <span className="muted">: {relationship.description}</span>}
</li>
))}
</ul>
</div>
)}
<div className="mt-6"> <div className="mt-6">
<IdentitySection <IdentitySection
character={character} character={character}
@@ -273,6 +262,23 @@ function CharacterSheet({
)} )}
</div> </div>
<RelationshipsSection
character={character}
allCharacters={allCharacters ?? []}
canWrite={canWrite}
onAdd={(relatedCharacterId, relationshipType, reciprocalRelationshipType, description) =>
addRelationship.mutate({
id: character.id,
relatedCharacterId,
relationshipType,
reciprocalRelationshipType,
description,
})
}
onRemove={(relationshipId) => removeRelationship.mutate(relationshipId)}
error={addRelationship.error}
/>
{(character.importance === 'Main' || character.arcStages.length > 0) && ( {(character.importance === 'Main' || character.arcStages.length > 0) && (
<CharacterArc <CharacterArc
projectId={projectId} projectId={projectId}
@@ -287,6 +293,7 @@ function CharacterSheet({
projectId={projectId} projectId={projectId}
characterId={character.id} characterId={character.id}
characterName={character.name} characterName={character.name}
arcStages={character.arcStages}
/> />
<OpenQuestions <OpenQuestions
@@ -311,6 +318,160 @@ function CharacterSheet({
) )
} }
function RelationshipsSection({
character,
allCharacters,
canWrite,
onAdd,
onRemove,
error,
}: {
character: Character
allCharacters: Character[]
canWrite: boolean
onAdd: (
relatedCharacterId: string,
relationshipType: string,
reciprocalRelationshipType: string | null,
description: string | null,
) => void
onRemove: (relationshipId: string) => void
error: unknown
}) {
const [adding, setAdding] = useState(false)
const [targetId, setTargetId] = useState('')
const [relationshipType, setRelationshipType] = useState('')
const [reciprocalRelationshipType, setReciprocalRelationshipType] = useState('')
const [description, setDescription] = useState('')
const candidates = allCharacters.filter(
(c) => c.id !== character.id && !character.relationships.some((r) => r.relatedCharacterId === c.id),
)
const targetName = candidates.find((c) => c.id === targetId)?.name ?? 'them'
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!targetId || !relationshipType.trim()) return
onAdd(targetId, relationshipType.trim(), reciprocalRelationshipType.trim() || null, description.trim() || null)
setAdding(false)
setTargetId('')
setRelationshipType('')
setReciprocalRelationshipType('')
setDescription('')
}
return (
<section id="character-relationships" className="card mt-6 p-5">
<h3 className="mb-3 text-sm font-semibold">Relationships</h3>
{character.relationships.length > 0 && (
<ul className="mb-3 grid gap-1 text-sm">
{character.relationships.map((relationship) => (
<li key={relationship.id} className="flex items-start justify-between gap-2">
<div>
<span className="font-medium">{character.name}</span>
<span className="muted"> is {relationship.relatedCharacterName}&rsquo;s </span>
<span className="font-medium">{relationship.relationshipType}</span>
{relationship.description && <span className="muted">: {relationship.description}</span>}
</div>
{canWrite && (
<button
type="button"
id={`remove-relationship-${relationship.id}`}
className="shrink-0 opacity-60 transition hover:opacity-100"
aria-label={`Remove relationship with ${relationship.relatedCharacterName}`}
onClick={() => onRemove(relationship.id)}
>
</button>
)}
</li>
))}
</ul>
)}
{character.relationships.length === 0 && !adding && (
<p className="mb-3 text-sm muted">No relationships recorded yet.</p>
)}
{canWrite && candidates.length > 0 && (
<>
{!adding ? (
<button className="btn" id="add-relationship-button" onClick={() => setAdding(true)}>
Add relationship
</button>
) : (
<form onSubmit={submit} className="card grid gap-2 p-3">
<label className="block">
<span className="label">Related to</span>
<select
id="add-relationship-character-select"
className="input"
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
autoFocus
>
<option value="">Select a character</option>
{candidates.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">{character.name} is {targetName}&rsquo;s</span>
<input
id="add-relationship-type-input"
className="input"
placeholder="e.g. sister, rival, servant"
value={relationshipType}
onChange={(e) => setRelationshipType(e.target.value)}
/>
</label>
<label className="block">
<span className="label">
{targetName} is {character.name}&rsquo;s (optional, defaults to the same)
</span>
<input
id="add-relationship-reciprocal-type-input"
className="input"
placeholder="e.g. brother, rival, employer"
value={reciprocalRelationshipType}
onChange={(e) => setReciprocalRelationshipType(e.target.value)}
/>
</label>
<label className="block">
<span className="label">Note (optional)</span>
<input
id="add-relationship-description-input"
className="input"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<div className="flex justify-end gap-2">
<button type="button" className="btn" onClick={() => setAdding(false)}>
Cancel
</button>
<button className="btn btn-primary" disabled={!targetId || !relationshipType.trim()}>
Add
</button>
</div>
</form>
)}
</>
)}
{error !== null && error !== undefined && (
<div className="mt-3">
<ErrorNote error={error} />
</div>
)}
</section>
)
}
function IdentitySection({ function IdentitySection({
character, character,
allCharacters, allCharacters,
+69 -2
View File
@@ -98,14 +98,14 @@ public class CharacterArcTests : ServiceTestFixture
public async Task A_character_dossier_carries_its_arc() public async Task A_character_dossier_carries_its_arc()
{ {
await Arcs.CreateAsync(_characterId, new CreateArcStageRequest( 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))!; var character = (await Characters.GetAsync(_characterId))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(character.ArcStages, Has.Count.EqualTo(1)); 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] [Test]
public async Task Asking_for_the_beats_of_a_character_who_does_not_exist_returns_null_rather_than_throwing() => 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); 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);
}
} }
@@ -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] [Test]
public async Task Deleting_a_character_detaches_it_from_beats_rather_than_deleting_them() public async Task Deleting_a_character_detaches_it_from_beats_rather_than_deleting_them()
{ {
@@ -72,7 +72,8 @@ public abstract class ServiceTestFixture
new AssignCharacterToBeatsRequestValidator(), new MoveBeatsRequestValidator()); new AssignCharacterToBeatsRequestValidator(), new MoveBeatsRequestValidator());
Arcs = new CharacterArcService( Arcs = new CharacterArcService(
Db.Context, Access, ArcLogs, Db.Context, Access, ArcLogs,
new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator()); new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator(),
new SetArcStageBeatsRequestValidator());
Questions = new OpenQuestionService( Questions = new OpenQuestionService(
Db.Context, Access, QuestionLogs, Db.Context, Access, QuestionLogs,
new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator()); new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator());