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:
@@ -104,6 +104,9 @@ dotnet_style_qualification_for_property = false:suggestion
|
||||
dotnet_style_qualification_for_method = false:warning
|
||||
dotnet_style_qualification_for_event = false:warning
|
||||
|
||||
|
||||
dotnet_diagnostic.CA1873.severity = silent
|
||||
|
||||
[*.cs]
|
||||
csharp_using_directive_placement = outside_namespace:silent
|
||||
csharp_prefer_simple_using_statement = true:suggestion
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1169
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
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -147,11 +147,11 @@ public static class CharacterTools
|
||||
[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,
|
||||
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("Position in the arc. Appended to the end when omitted.")] int? sortOrder = null) =>
|
||||
api.PostAsync($"/api/characters/{characterId}/arc",
|
||||
new { title, sortOrder, description, chapterId }, ct);
|
||||
new { title, sortOrder, result, chapterId }, ct);
|
||||
|
||||
[McpServerTool(Name = "update_arc_stage")]
|
||||
[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,
|
||||
CancellationToken ct,
|
||||
[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("Position in the arc.")] int? sortOrder = null) =>
|
||||
api.PatchAsync($"/api/arc-stages/{arcStageId}",
|
||||
new { title, sortOrder, description, chapterId }, ct);
|
||||
new { title, sortOrder, result, chapterId }, ct);
|
||||
|
||||
[McpServerTool(Name = "delete_arc_stage")]
|
||||
[Description("Remove a stage from a character's arc.")]
|
||||
@@ -184,17 +184,34 @@ public static class CharacterTools
|
||||
CancellationToken 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")]
|
||||
[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(
|
||||
NovelApiClient api,
|
||||
[Description("Id of the character the relationship belongs to.")] Guid characterId,
|
||||
[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,
|
||||
[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) =>
|
||||
api.PostAsync($"/api/characters/{characterId}/relationships",
|
||||
new { relatedCharacterId, relationshipType, description }, ct);
|
||||
new { relatedCharacterId, relationshipType, reciprocalRelationshipType, description }, ct);
|
||||
|
||||
[McpServerTool(Name = "link_character_identity")]
|
||||
[Description("Record that this character is really another character — e.g. a character introduced "
|
||||
|
||||
@@ -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) =>
|
||||
useQuery({
|
||||
queryKey: keys.characterBeats(characterId ?? ''),
|
||||
@@ -214,7 +248,7 @@ export const useCharacterBeats = (characterId: string | undefined) =>
|
||||
export function useCreateArcStage(projectId: string) {
|
||||
const qc = useQueryClient()
|
||||
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),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
|
||||
})
|
||||
@@ -223,12 +257,24 @@ export function useCreateArcStage(projectId: string) {
|
||||
export function useUpdateArcStage(projectId: string) {
|
||||
const qc = useQueryClient()
|
||||
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),
|
||||
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) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -151,10 +151,11 @@ export interface ArcStage {
|
||||
characterId: string
|
||||
sortOrder: number
|
||||
title: string
|
||||
description: string | null
|
||||
result: string | null
|
||||
chapterId: string | null
|
||||
chapterNumber: number | null
|
||||
chapterTitle: string | null
|
||||
beats: CharacterBeat[]
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -167,6 +168,7 @@ export interface CharacterBeat {
|
||||
title: string
|
||||
whatHappened: string | null
|
||||
whatsNext: string | null
|
||||
arcStageId: string | null
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
useCharacterBeats,
|
||||
useChapters,
|
||||
useCreateArcStage,
|
||||
useDeleteArcStage,
|
||||
useReorderArcStages,
|
||||
useSetArcStageBeats,
|
||||
useUpdateArcStage,
|
||||
} from '../api/hooks'
|
||||
import type { ArcStage, Character } from '../api/types'
|
||||
@@ -24,12 +26,14 @@ export function CharacterArc({
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const { data: chapters } = useChapters(projectId)
|
||||
const { data: beats } = useCharacterBeats(character.id)
|
||||
const create = useCreateArcStage(projectId)
|
||||
const reorder = useReorderArcStages(projectId)
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
|
||||
const stages = character.arcStages
|
||||
const unassignedBeats = (beats ?? []).filter((b) => b.arcStageId === null)
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -56,8 +60,9 @@ export function CharacterArc({
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-3 text-xs muted">
|
||||
The changes {character.name} goes through, in order. Pin a stage to the chapter it
|
||||
lands in and it links into that outline.
|
||||
The sections {character.name}’s arc breaks into, in order — each one a short span of
|
||||
beats and what it results in for them. Pin a section to the chapter it lands in and it
|
||||
links into that outline.
|
||||
</p>
|
||||
|
||||
{stages.length > 0 && (
|
||||
@@ -68,6 +73,7 @@ export function CharacterArc({
|
||||
projectId={projectId}
|
||||
stage={stage}
|
||||
chapters={chapters ?? []}
|
||||
unassignedBeats={unassignedBeats}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < stages.length - 1}
|
||||
onMove={(delta) => move(index, delta)}
|
||||
@@ -78,11 +84,18 @@ export function CharacterArc({
|
||||
</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 && (
|
||||
<form onSubmit={submit} className="mt-3 flex gap-2">
|
||||
<input
|
||||
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}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
@@ -105,6 +118,7 @@ function ArcStageRow({
|
||||
projectId,
|
||||
stage,
|
||||
chapters,
|
||||
unassignedBeats,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
onMove,
|
||||
@@ -114,6 +128,7 @@ function ArcStageRow({
|
||||
projectId: string
|
||||
stage: ArcStage
|
||||
chapters: { id: string; number: number; title: string }[]
|
||||
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
|
||||
canMoveUp: boolean
|
||||
canMoveDown: boolean
|
||||
onMove: (delta: number) => void
|
||||
@@ -122,6 +137,16 @@ function ArcStageRow({
|
||||
}) {
|
||||
const update = useUpdateArcStage(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 (
|
||||
<li
|
||||
@@ -138,14 +163,59 @@ function ArcStageRow({
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
value={stage.description}
|
||||
value={stage.result}
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="What shifts here, and what it costs them."
|
||||
onCommit={(description) => update.mutate({ id: stage.id, description })}
|
||||
placeholder="What this results in for them — what shifts, and what it costs."
|
||||
onCommit={(result) => update.mutate({ id: stage.id, result })}
|
||||
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">
|
||||
<select
|
||||
className="input max-w-[16rem] py-1 text-xs"
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useCharacterBeats } from '../api/hooks'
|
||||
import type { ArcStage } from '../api/types'
|
||||
import { ErrorNote, Spinner } from './ui'
|
||||
|
||||
export function CharacterBeats({
|
||||
projectId,
|
||||
characterId,
|
||||
characterName,
|
||||
arcStages,
|
||||
}: {
|
||||
projectId: string
|
||||
characterId: string
|
||||
characterName: string
|
||||
arcStages: ArcStage[]
|
||||
}) {
|
||||
const { data: beats, isPending, error } = useCharacterBeats(characterId)
|
||||
const stageTitleById = new Map(arcStages.map((s) => [s.id, s.title]))
|
||||
|
||||
return (
|
||||
<section className="card mt-6 p-5">
|
||||
@@ -33,6 +37,7 @@ export function CharacterBeats({
|
||||
<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">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 font-semibold">What’s next</th>
|
||||
</tr>
|
||||
@@ -51,6 +56,9 @@ export function CharacterBeats({
|
||||
<div className="text-xs muted">{beat.chapterTitle}</div>
|
||||
</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 muted">{beat.whatsNext}</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
useAddRelationship,
|
||||
useChapters,
|
||||
useCharacters,
|
||||
useDeleteCharacter,
|
||||
useLinkCharacterIdentity,
|
||||
useProject,
|
||||
useRemoveRelationship,
|
||||
useTags,
|
||||
useUnlinkCharacterIdentity,
|
||||
useUpdateCharacter,
|
||||
@@ -84,6 +86,8 @@ function CharacterSheet({
|
||||
const remove = useDeleteCharacter(projectId)
|
||||
const linkIdentity = useLinkCharacterIdentity(projectId)
|
||||
const unlinkIdentity = useUnlinkCharacterIdentity(projectId)
|
||||
const addRelationship = useAddRelationship(projectId)
|
||||
const removeRelationship = useRemoveRelationship(projectId)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const patch = (body: Partial<Omit<Character, 'tags' | 'aliases'>> & { tags?: string[]; aliases?: string[] }) =>
|
||||
update.mutate({ id: character.id, ...body })
|
||||
@@ -233,21 +237,6 @@ function CharacterSheet({
|
||||
/>
|
||||
</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">
|
||||
<IdentitySection
|
||||
character={character}
|
||||
@@ -273,6 +262,23 @@ function CharacterSheet({
|
||||
)}
|
||||
</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) && (
|
||||
<CharacterArc
|
||||
projectId={projectId}
|
||||
@@ -287,6 +293,7 @@ function CharacterSheet({
|
||||
projectId={projectId}
|
||||
characterId={character.id}
|
||||
characterName={character.name}
|
||||
arcStages={character.arcStages}
|
||||
/>
|
||||
|
||||
<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}’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}’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}’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({
|
||||
character,
|
||||
allCharacters,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -72,7 +72,8 @@ public abstract class ServiceTestFixture
|
||||
new AssignCharacterToBeatsRequestValidator(), new MoveBeatsRequestValidator());
|
||||
Arcs = new CharacterArcService(
|
||||
Db.Context, Access, ArcLogs,
|
||||
new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator());
|
||||
new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator(),
|
||||
new SetArcStageBeatsRequestValidator());
|
||||
Questions = new OpenQuestionService(
|
||||
Db.Context, Access, QuestionLogs,
|
||||
new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator());
|
||||
|
||||
Reference in New Issue
Block a user