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