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