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), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics), ("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) foreach (var test in tests)
@ -86,6 +89,53 @@ static void RepairsTrailingDecimalConfidence()
Assert(result.RepairedJson.Contains("\"confidence\":0.0", StringComparison.Ordinal), result.RepairedJson); 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() static void StripsMarkdownFence()
{ {
var result = StoryIntelligenceJsonRepair.Repair(""" var result = StoryIntelligenceJsonRepair.Repair("""

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using PlotLine.Models;
using PlotLine.Services; using PlotLine.Services;
namespace PlotLine.Controllers; 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) 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); 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<IChapterService, ChapterService>();
builder.Services.AddScoped<ISceneService, SceneService>(); builder.Services.AddScoped<ISceneService, SceneService>();
builder.Services.AddScoped<IStoryStateService, StoryStateService>(); builder.Services.AddScoped<IStoryStateService, StoryStateService>();
builder.Services.AddScoped<IStoryContextService, StoryContextService>();
builder.Services.AddScoped<ITimelineService, TimelineService>(); builder.Services.AddScoped<ITimelineService, TimelineService>();
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>(); builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>(); builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>();

View File

@ -146,6 +146,23 @@ public interface IStoryStateService
Task<List<ContinuityWarningAcknowledgement>> GetAcknowledgementsAsync(int projectId); 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 public interface IPlotService
{ {
Task<PlotLineListViewModel?> GetPlotLinesAsync(int projectId); Task<PlotLineListViewModel?> GetPlotLinesAsync(int projectId);
@ -313,6 +330,7 @@ public interface IStoryBibleService
public interface IRelationshipMapService 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); 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( public sealed class TimelineService(
ITimelineRepository timelineRepository, ITimelineRepository timelineRepository,
ITimelinePresetRepository presets, ITimelinePresetRepository presets,
ITimelineSettingsRepository timelineSettings, ITimelineSettingsRepository timelineSettings,
IProjectRepository projects, IProjectRepository projects,
IBookRepository books, IBookRepository books,
IStoryContextService storyContext,
ISceneService scenes, ISceneService scenes,
IAssetRepository assets, IAssetRepository assets,
ICharacterRepository characters, ICharacterRepository characters,
@ -2141,9 +2216,10 @@ public sealed class TimelineService(
var characterLanes = settings.ShowCharacterAppearances var characterLanes = settings.ShowCharacterAppearances
? await TimeTimelineLoadAsync("TimelineService.BuildCharacterLanes", () => Task.FromResult(BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes)), projectId, bookId) ? await TimeTimelineLoadAsync("TimelineService.BuildCharacterLanes", () => Task.FromResult(BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes)), projectId, bookId)
: []; : [];
var selectedScene = selectedSceneId.HasValue var selectedContext = selectedSceneId.HasValue
? await TimeTimelineLoadAsync("SceneService.GetEditAsync", () => scenes.GetEditAsync(selectedSceneId.Value), projectId, bookId) ? await TimeTimelineLoadAsync("StoryContextService.GetForSceneAsync", () => storyContext.GetForSceneAsync(selectedSceneId.Value, projectId, bookId), projectId, bookId)
: null; : null;
var selectedScene = selectedContext?.Scene;
if (selectedScene is not null) if (selectedScene is not null)
{ {
selectedScene.ReturnProjectID = projectId; selectedScene.ReturnProjectID = projectId;
@ -5340,6 +5416,13 @@ public sealed class StoryBibleService(IProjectRepository projects, IStoryBibleRe
public sealed class RelationshipMapService(IProjectRepository projects, ICharacterRepository characters) : IRelationshipMapService 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) public async Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork)
{ {
if (projectId <= 0) if (projectId <= 0)

View File

@ -416,6 +416,7 @@ public sealed class SceneEditViewModel
public int? ReturnProjectID { get; set; } public int? ReturnProjectID { get; set; }
public int? ReturnBookID { get; set; } public int? ReturnBookID { get; set; }
public bool ReturnToTimeline { get; set; } public bool ReturnToTimeline { get; set; }
public StoryPosition? Position => StoryPosition.FromScene(this);
public Chapter? Chapter { get; set; } public Chapter? Chapter { get; set; }
public Book? Book { get; set; } public Book? Book { get; set; }