Establish canonical story position context

This commit is contained in:
Nick Beckley 2026-08-17 14:17:19 +00:00
parent f6b4263f31
commit 9f0fdbc366
6 changed files with 202 additions and 4 deletions

View File

@ -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("""

View File

@ -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<IActionResult> 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);
}
}

View File

@ -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;
}

View File

@ -167,6 +167,7 @@ public class Program
builder.Services.AddScoped<IChapterService, ChapterService>();
builder.Services.AddScoped<ISceneService, SceneService>();
builder.Services.AddScoped<IStoryStateService, StoryStateService>();
builder.Services.AddScoped<IStoryContextService, StoryContextService>();
builder.Services.AddScoped<ITimelineService, TimelineService>();
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>();

View File

@ -146,6 +146,23 @@ public interface IStoryStateService
Task<List<ContinuityWarningAcknowledgement>> GetAcknowledgementsAsync(int projectId);
}
public interface IStoryContextService
{
Task<StoryContextViewModel?> GetAsync(StoryPosition position);
Task<StoryContextViewModel?> 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<PlotLineListViewModel?> GetPlotLinesAsync(int projectId);
@ -313,6 +330,7 @@ public interface IStoryBibleService
public interface IRelationshipMapService
{
Task<RelationshipMapViewModel?> GetAsync(StoryPosition position, int? focusCharacterId, bool showFullNetwork);
Task<RelationshipMapViewModel?> 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<StoryContextViewModel?> 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<StoryContextViewModel?> 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<RelationshipMapViewModel?> GetAsync(StoryPosition position, int? focusCharacterId, bool showFullNetwork)
{
return !position.IsValid
? Task.FromResult<RelationshipMapViewModel?>(null)
: GetAsync(position.ProjectId, position.BookId, position.ChapterId, position.SceneId, focusCharacterId, showFullNetwork);
}
public async Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork)
{
if (projectId <= 0)

View File

@ -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; }