From 9f0fdbc366213e141547c8ca8c396780cd404d88 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 17 Aug 2026 14:17:19 +0000 Subject: [PATCH] Establish canonical story position context --- PlotLine.Tests/Program.cs | 52 ++++++++++- .../Controllers/RelationshipMapController.cs | 6 +- PlotLine/Models/StoryPosition.cs | 59 +++++++++++++ PlotLine/Program.cs | 1 + PlotLine/Services/CoreServices.cs | 87 ++++++++++++++++++- PlotLine/ViewModels/CoreViewModels.cs | 1 + 6 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 PlotLine/Models/StoryPosition.cs diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 502fb2f..d80f5c4 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -70,7 +70,10 @@ var tests = new (string Name, Action Test)[] ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), ("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics), - ("Illustration evidence trace does not recursively store previous evidence", IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence) + ("Illustration evidence trace does not recursively store previous evidence", IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence), + ("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions), + ("StoryPosition builds canonical runtime scene navigation", StoryPositionBuildsCanonicalRuntimeSceneNavigation), + ("StoryPosition derives canonical scene context identity", StoryPositionDerivesCanonicalSceneContextIdentity) }; foreach (var test in tests) @@ -86,6 +89,53 @@ static void RepairsTrailingDecimalConfidence() Assert(result.RepairedJson.Contains("\"confidence\":0.0", StringComparison.Ordinal), result.RepairedJson); } +static void StoryPositionRejectsInvalidRuntimeScenePositions() +{ + Assert(StoryPosition.FromRuntimeCurrentScene(new WordCompanionRuntimeCurrentSceneRequest + { + DocumentGuid = Guid.NewGuid(), + ProjectId = 1, + BookId = 2, + ChapterId = 3, + SceneId = 0 + }) is null, "Runtime scene positions require a valid SceneId."); + + Assert(StoryPosition.FromTimelineSelection(0, 2, 3, 4) is null, "Story positions require a valid ProjectId."); +} + +static void StoryPositionBuildsCanonicalRuntimeSceneNavigation() +{ + var response = new WordCompanionRuntimeCurrentSceneResponse + { + ProjectId = 10, + BookId = 20, + ChapterId = 30, + SceneId = 40, + SceneUrl = "/Scenes/Edit/40" + }; + + var position = response.Position; + Assert(position is not null, "Runtime response should expose a canonical StoryPosition."); + Assert(position!.ProjectId == 10 && position.BookId == 20 && position.ChapterId == 30 && position.SceneId == 40, "Runtime response position IDs were not preserved."); + Assert(position.SceneEditUrl == "/Scenes/Edit/40", "Scene positions must use the canonical Scene Edit route."); +} + +static void StoryPositionDerivesCanonicalSceneContextIdentity() +{ + var model = new SceneEditViewModel + { + SceneID = 7, + ChapterID = 6, + Project = new Project { ProjectID = 4 }, + Book = new Book { BookID = 5 }, + Chapter = new Chapter { ChapterID = 6 } + }; + + var position = StoryPosition.FromScene(model); + Assert(position is not null, "Scene workspace should expose a canonical StoryPosition."); + Assert(position!.ProjectId == 4 && position.BookId == 5 && position.ChapterId == 6 && position.SceneId == 7, "Scene workspace position IDs were not preserved."); +} + static void StripsMarkdownFence() { var result = StoryIntelligenceJsonRepair.Repair(""" diff --git a/PlotLine/Controllers/RelationshipMapController.cs b/PlotLine/Controllers/RelationshipMapController.cs index 3fd15d2..a522e4f 100644 --- a/PlotLine/Controllers/RelationshipMapController.cs +++ b/PlotLine/Controllers/RelationshipMapController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; +using PlotLine.Models; using PlotLine.Services; namespace PlotLine.Controllers; @@ -9,7 +10,10 @@ public sealed class RelationshipMapController(IRelationshipMapService relationsh { public async Task Index(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork = false) { - var model = await relationshipMap.GetAsync(projectId, bookId, chapterId, sceneId, focusCharacterId, showFullNetwork); + var position = StoryPosition.FromTimelineSelection(projectId, bookId, chapterId, sceneId); + var model = position is null + ? null + : await relationshipMap.GetAsync(position, focusCharacterId, showFullNetwork); return model is null ? NotFound() : View(model); } } diff --git a/PlotLine/Models/StoryPosition.cs b/PlotLine/Models/StoryPosition.cs new file mode 100644 index 0000000..d3828f0 --- /dev/null +++ b/PlotLine/Models/StoryPosition.cs @@ -0,0 +1,59 @@ +using PlotLine.ViewModels; + +namespace PlotLine.Models; + +public sealed record StoryPosition(int ProjectId, int? BookId = null, int? ChapterId = null, int? SceneId = null) +{ + public bool IsValid => ProjectId > 0 && (BookId is null or > 0) && (ChapterId is null or > 0) && (SceneId is null or > 0); + public bool IsScenePosition => SceneId.HasValue; + + public static StoryPosition? FromScene(SceneEditViewModel? scene) + { + if (scene?.Project is null) + { + return null; + } + + return new StoryPosition(scene.Project.ProjectID, scene.Book?.BookID, scene.ChapterID, scene.SceneID > 0 ? scene.SceneID : null); + } + + public static StoryPosition? FromScene(Scene? scene, int projectId) + { + if (scene is null || projectId <= 0) + { + return null; + } + + return new StoryPosition(projectId, scene.BookID > 0 ? scene.BookID : null, scene.ChapterID, scene.SceneID); + } + + public static StoryPosition? FromRuntimeCurrentScene(WordCompanionRuntimeCurrentSceneRequest request) + { + var position = new StoryPosition(request.ProjectId, request.BookId, request.ChapterId, request.SceneId); + return position.IsValid ? position : null; + } + + public static StoryPosition? FromRuntimeCurrentScene(WordCompanionRuntimeCurrentSceneResponse? response) + { + if (response is null) + { + return null; + } + + var position = new StoryPosition(response.ProjectId, response.BookId, response.ChapterId, response.SceneId); + return position.IsValid ? position : null; + } + + public static StoryPosition? FromTimelineSelection(int projectId, int? bookId, int? chapterId, int? sceneId) + { + if (projectId <= 0) + { + return null; + } + + var position = new StoryPosition(projectId, bookId, chapterId, sceneId); + return position.IsValid ? position : null; + } + + public string? SceneEditUrl => SceneId.HasValue ? $"/Scenes/Edit/{SceneId.Value}" : null; +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 32bdee2..d0bffdf 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -167,6 +167,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index b7734c6..65bc5bd 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -146,6 +146,23 @@ public interface IStoryStateService Task> GetAcknowledgementsAsync(int projectId); } +public interface IStoryContextService +{ + Task GetAsync(StoryPosition position); + Task GetForSceneAsync(int sceneId, int? projectId = null, int? bookId = null); +} + +public sealed class StoryContextViewModel +{ + public StoryPosition Position { get; init; } = new(0); + public Project? Project { get; init; } + public Book? Book { get; init; } + public Chapter? Chapter { get; init; } + public SceneEditViewModel? Scene { get; init; } + public StoryStateViewModel StoryState { get; init; } = new(); + public string? CanonicalUrl => Position.SceneEditUrl; +} + public interface IPlotService { Task GetPlotLinesAsync(int projectId); @@ -313,6 +330,7 @@ public interface IStoryBibleService public interface IRelationshipMapService { + Task GetAsync(StoryPosition position, int? focusCharacterId, bool showFullNetwork); Task GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork); } @@ -2017,12 +2035,69 @@ public sealed class SceneService( } } +public sealed class StoryContextService(ISceneService scenes) : IStoryContextService +{ + public async Task GetAsync(StoryPosition position) + { + if (!position.IsValid) + { + return null; + } + + if (position.SceneId.HasValue) + { + return await GetForSceneAsync(position.SceneId.Value, position.ProjectId, position.BookId); + } + + return new StoryContextViewModel + { + Position = position + }; + } + + public async Task GetForSceneAsync(int sceneId, int? projectId = null, int? bookId = null) + { + if (sceneId <= 0) + { + return null; + } + + var scene = await scenes.GetEditAsync(sceneId); + var position = StoryPosition.FromScene(scene); + if (position is null) + { + return null; + } + + if (projectId.HasValue && projectId.Value != position.ProjectId) + { + return null; + } + + if (bookId.HasValue && position.BookId.HasValue && bookId.Value != position.BookId.Value) + { + return null; + } + + return new StoryContextViewModel + { + Position = position, + Project = scene!.Project, + Book = scene.Book, + Chapter = scene.Chapter, + Scene = scene, + StoryState = scene.StoryState + }; + } +} + public sealed class TimelineService( ITimelineRepository timelineRepository, ITimelinePresetRepository presets, ITimelineSettingsRepository timelineSettings, IProjectRepository projects, IBookRepository books, + IStoryContextService storyContext, ISceneService scenes, IAssetRepository assets, ICharacterRepository characters, @@ -2141,9 +2216,10 @@ public sealed class TimelineService( var characterLanes = settings.ShowCharacterAppearances ? await TimeTimelineLoadAsync("TimelineService.BuildCharacterLanes", () => Task.FromResult(BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes)), projectId, bookId) : []; - var selectedScene = selectedSceneId.HasValue - ? await TimeTimelineLoadAsync("SceneService.GetEditAsync", () => scenes.GetEditAsync(selectedSceneId.Value), projectId, bookId) + var selectedContext = selectedSceneId.HasValue + ? await TimeTimelineLoadAsync("StoryContextService.GetForSceneAsync", () => storyContext.GetForSceneAsync(selectedSceneId.Value, projectId, bookId), projectId, bookId) : null; + var selectedScene = selectedContext?.Scene; if (selectedScene is not null) { selectedScene.ReturnProjectID = projectId; @@ -5340,6 +5416,13 @@ public sealed class StoryBibleService(IProjectRepository projects, IStoryBibleRe public sealed class RelationshipMapService(IProjectRepository projects, ICharacterRepository characters) : IRelationshipMapService { + public Task GetAsync(StoryPosition position, int? focusCharacterId, bool showFullNetwork) + { + return !position.IsValid + ? Task.FromResult(null) + : GetAsync(position.ProjectId, position.BookId, position.ChapterId, position.SceneId, focusCharacterId, showFullNetwork); + } + public async Task GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork) { if (projectId <= 0) diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index bc82c92..d26bd13 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -416,6 +416,7 @@ public sealed class SceneEditViewModel public int? ReturnProjectID { get; set; } public int? ReturnBookID { get; set; } public bool ReturnToTimeline { get; set; } + public StoryPosition? Position => StoryPosition.FromScene(this); public Chapter? Chapter { get; set; } public Book? Book { get; set; }