Phase 12A: Story State Engine and Scene Inspector Integration for the new Continuity Explorer feature.

This commit is contained in:
Nick Beckley 2026-06-14 18:50:58 +01:00
parent d07a11cd06
commit eafa9927c7
4 changed files with 314 additions and 0 deletions

View File

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

View File

@ -111,6 +111,11 @@ public interface ISceneService
Task DeleteDependencyAsync(int sceneDependencyId);
}
public interface IStoryStateService
{
Task<StoryStateViewModel> GetStoryStateAsync(int sceneId);
}
public interface IPlotService
{
Task<PlotLineListViewModel?> GetPlotLinesAsync(int projectId);
@ -983,6 +988,7 @@ public sealed class SceneService(
ISubscriptionService subscriptions,
IProjectActivityService activity,
IStorageService storage,
IStoryStateService storyState,
ICurrentUserService currentUser) : ISceneService
{
public async Task<SceneEditViewModel?> GetCreateAsync(int chapterId)
@ -1195,6 +1201,7 @@ public sealed class SceneService(
model.ChecklistItems = model.SceneID == 0 ? [] : await writerWorkspace.ListChecklistItemsAsync(model.SceneID);
model.Attachments = model.SceneID == 0 ? [] : await writerWorkspace.ListAttachmentsAsync(model.SceneID);
model.StorageUsage = model.SceneID == 0 ? null : await storage.GetSceneStorageUsageAsync(model.SceneID);
model.StoryState = model.SceneID == 0 ? new StoryStateViewModel() : await storyState.GetStoryStateAsync(model.SceneID);
model.SceneNoteTypeOptions = ChapterService.ToSelectList(noteTypes, x => x.SceneNoteTypeID, x => x.TypeName);
model.SceneAttachmentTypeOptions = ChapterService.ToSelectList(attachmentTypes, x => x.SceneAttachmentTypeID, x => x.TypeName);
model.PlotLineOptions = ToOptionalSelectList(plotLines, x => x.PlotLineID, x => x.PlotLineName);
@ -2196,6 +2203,209 @@ public sealed class TimelineService(
}
}
public sealed class StoryStateService(
IBookRepository books,
IChapterRepository chapters,
ISceneRepository scenes,
ITimelineRepository timelineRepository,
IAssetRepository assets,
ICharacterRepository characters,
ILocationRepository locations) : IStoryStateService
{
public async Task<StoryStateViewModel> GetStoryStateAsync(int sceneId)
{
var currentScene = await scenes.GetAsync(sceneId);
if (currentScene is null)
{
return new StoryStateViewModel();
}
var chapter = await chapters.GetAsync(currentScene.ChapterID);
var book = chapter is null ? null : await books.GetAsync(chapter.BookID);
if (book is null)
{
return new StoryStateViewModel();
}
var timeline = await timelineRepository.GetByProjectAsync(book.ProjectID, book.BookID, false, [], false);
var orderedScenes = OrderScenes(timeline).ToList();
var currentSceneIndex = orderedScenes.FindIndex(x => x.SceneID == sceneId);
if (currentSceneIndex < 0)
{
return new StoryStateViewModel();
}
var sceneIndexes = orderedScenes
.Select((scene, index) => new { scene.SceneID, Index = index })
.ToDictionary(x => x.SceneID, x => x.Index);
var scenesThroughCurrent = orderedScenes.Take(currentSceneIndex + 1).ToList();
var allowedSceneIds = scenesThroughCurrent.Select(x => x.SceneID).ToHashSet();
var characterTimeline = await characters.GetTimelineAsync(book.ProjectID, book.BookID);
var assetTimeline = await assets.GetTimelineAsync(book.ProjectID, book.BookID);
var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughCurrent);
var assetCustody = await GetAssetCustodyThroughCurrentAsync(assetTimeline.Assets);
return new StoryStateViewModel
{
Characters = BuildCharacterState(characterTimeline.Characters, characterTimeline.Appearances, sceneId, allowedSceneIds, sceneIndexes),
Assets = BuildAssetState(assetTimeline.Assets, assetTimeline.Events, assetLocations, assetCustody, sceneId, allowedSceneIds, sceneIndexes, orderedScenes.ToDictionary(x => x.SceneID))
};
}
private static IEnumerable<Scene> OrderScenes(TimelineData timeline)
{
var chaptersById = timeline.Chapters.ToDictionary(x => x.ChapterID);
var bookSortOrders = timeline.Books.ToDictionary(x => x.BookID, x => x.SortOrder);
var chapterSortOrders = timeline.Chapters.ToDictionary(x => x.ChapterID, x => x.SortOrder);
return timeline.Scenes
.OrderBy(scene => bookSortOrders.GetValueOrDefault(chaptersById.GetValueOrDefault(scene.ChapterID)?.BookID ?? 0))
.ThenBy(scene => chapterSortOrders.GetValueOrDefault(scene.ChapterID))
.ThenBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID);
}
private async Task<IReadOnlyList<SceneAssetLocation>> GetAssetLocationsThroughCurrentAsync(IReadOnlyList<Scene> scenesThroughCurrent)
{
var rows = new List<SceneAssetLocation>();
foreach (var scene in scenesThroughCurrent)
{
rows.AddRange(await locations.ListSceneAssetLocationsAsync(scene.SceneID));
}
return rows;
}
private async Task<IReadOnlyList<AssetCustodyEvent>> GetAssetCustodyThroughCurrentAsync(IReadOnlyList<StoryAsset> projectAssets)
{
var rows = new List<AssetCustodyEvent>();
foreach (var asset in projectAssets)
{
rows.AddRange(await assets.ListCustodyByAssetAsync(asset.StoryAssetID));
}
return rows;
}
private static List<CharacterStoryStateViewModel> BuildCharacterState(
IReadOnlyList<Character> projectCharacters,
IReadOnlyList<SceneCharacter> appearances,
int currentSceneId,
IReadOnlySet<int> allowedSceneIds,
IReadOnlyDictionary<int, int> sceneIndexes)
{
var appearancesWithLocations = appearances
.Where(x => allowedSceneIds.Contains(x.SceneID) && x.LocationID.HasValue)
.ToList();
return projectCharacters
.OrderByDescending(x => x.CharacterImportance ?? 0)
.ThenBy(x => x.CharacterName)
.Select(character =>
{
var latest = appearancesWithLocations
.Where(x => x.CharacterID == character.CharacterID)
.OrderByDescending(x => sceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ThenByDescending(x => x.UpdatedDate)
.FirstOrDefault();
if (latest is null)
{
return new CharacterStoryStateViewModel
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName
};
}
return new CharacterStoryStateViewModel
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName,
CurrentLocation = latest.LocationPath ?? latest.LocationName ?? "Unknown",
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
LastConfirmed = SceneLabel(latest.SceneNumber, latest.SceneTitle)
};
})
.ToList();
}
private static List<AssetStoryStateViewModel> BuildAssetState(
IReadOnlyList<StoryAsset> projectAssets,
IReadOnlyList<AssetEvent> assetEvents,
IReadOnlyList<SceneAssetLocation> assetLocations,
IReadOnlyList<AssetCustodyEvent> assetCustody,
int currentSceneId,
IReadOnlySet<int> allowedSceneIds,
IReadOnlyDictionary<int, int> sceneIndexes,
IReadOnlyDictionary<int, Scene> scenesById)
{
var stateAssignments = assetEvents
.Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.ToStateName))
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.ToStateName!, 1, x.UpdatedDate));
var locationAssignments = assetLocations
.Where(x => allowedSceneIds.Contains(x.SceneID))
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.LocationPath, 2, x.UpdatedDate));
var custodyAssignments = assetCustody
.Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.CustodianSummary))
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.CustodianSummary!, 3, x.UpdatedDate));
var assignments = stateAssignments
.Concat(locationAssignments)
.Concat(custodyAssignments)
.ToList();
return projectAssets
.OrderByDescending(x => x.Importance)
.ThenBy(x => x.AssetName)
.Select(asset =>
{
var latest = assignments
.Where(x => x.StoryAssetID == asset.StoryAssetID)
.OrderByDescending(x => sceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ThenByDescending(x => x.Priority)
.ThenByDescending(x => x.UpdatedDate)
.FirstOrDefault();
if (latest is null)
{
return new AssetStoryStateViewModel
{
StoryAssetID = asset.StoryAssetID,
AssetName = asset.AssetName
};
}
return new AssetStoryStateViewModel
{
StoryAssetID = asset.StoryAssetID,
AssetName = asset.AssetName,
CurrentHolderOrLocation = latest.Label,
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
LastConfirmed = scenesById.TryGetValue(latest.SceneID, out var scene)
? SceneLabel(scene.SceneNumber, scene.SceneTitle)
: "Unknown"
};
})
.ToList();
}
private static string SceneLabel(decimal sceneNumber, string sceneTitle)
{
return string.IsNullOrWhiteSpace(sceneTitle)
? $"Scene {sceneNumber:g}"
: $"Scene {sceneNumber:g}: {sceneTitle}";
}
private sealed record AssetStateAssignment(
int StoryAssetID,
int SceneID,
string Label,
int Priority,
DateTime UpdatedDate);
}
public sealed class PlotService(IProjectRepository projects, IBookRepository books, IPlotRepository plots, IProjectActivityService activity) : IPlotService
{
public async Task<PlotLineListViewModel?> GetPlotLinesAsync(int projectId)

View File

@ -221,6 +221,7 @@ public sealed class SceneEditViewModel
public IReadOnlyList<SelectListItem> SceneNoteTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> SceneAttachmentTypeOptions { get; set; } = [];
public StorageUsage? StorageUsage { get; set; }
public StoryStateViewModel StoryState { get; set; } = new();
public int? ReturnProjectID { get; set; }
public int? ReturnBookID { get; set; }
@ -236,6 +237,30 @@ public sealed class SceneEditViewModel
public IReadOnlyList<ScenePurposeType> ScenePurposeTypes { get; set; } = [];
}
public sealed class StoryStateViewModel
{
public List<CharacterStoryStateViewModel> Characters { get; set; } = [];
public List<AssetStoryStateViewModel> Assets { get; set; } = [];
}
public sealed class CharacterStoryStateViewModel
{
public int CharacterID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public string CurrentLocation { get; set; } = "Unknown";
public string State { get; set; } = "Unknown";
public string LastConfirmed { get; set; } = "Never";
}
public sealed class AssetStoryStateViewModel
{
public int StoryAssetID { get; set; }
public string AssetName { get; set; } = string.Empty;
public string CurrentHolderOrLocation { get; set; } = "Unknown";
public string State { get; set; } = "Unknown";
public string LastConfirmed { get; set; } = "Never";
}
public sealed class SceneWorkflowEditViewModel
{
public int SceneID { get; set; }

View File

@ -20,6 +20,15 @@
var exactDateModeId = Model.TimeModes.FirstOrDefault(x => string.Equals(x.Text, "Exact Date", StringComparison.OrdinalIgnoreCase))?.Value ?? string.Empty;
}
@functions {
private static string StoryStateBadgeClass(string state) => state switch
{
"Explicit" => "bg-success",
"Inferred" => "bg-secondary",
_ => "bg-warning text-dark"
};
}
<div class="scene-inspector-root" data-scene-id="@Model.SceneID">
<section class="inspector-section scene-overview-card" data-section-title="Overview" data-section-id="inspector-overview" data-inspector-search-text="@($"{Model.SceneTitle} {povCharacter} {primaryLocation} {timeLabel} {revisionStatus} {string.Join(' ', selectedPurposeLabels)}")">
<div>
@ -77,6 +86,7 @@
<a href="#inspector-asset-events">Assets</a>
<a href="#inspector-asset-locations">Locations</a>
<a href="#inspector-asset-custody">Custody</a>
<a href="#inspector-story-state">Story State</a>
<a href="#inspector-writer">Writer</a>
<a href="#inspector-checklist">Checklist</a>
@ -1203,6 +1213,74 @@
</form>
</section>
<section class="inspector-section" data-section-title="Story State" data-section-id="inspector-story-state" data-inspector-search-text="@($"Story State {string.Join(' ', Model.StoryState.Characters.Select(x => $"{x.CharacterName} {x.CurrentLocation} {x.State} {x.LastConfirmed}"))} {string.Join(' ', Model.StoryState.Assets.Select(x => $"{x.AssetName} {x.CurrentHolderOrLocation} {x.State} {x.LastConfirmed}"))}")">
<h3>Story State</h3>
<h4 class="inspector-subheading">Characters</h4>
@if (!Model.StoryState.Characters.Any())
{
<p class="muted">No project characters found.</p>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle mb-3">
<thead>
<tr>
<th>Character</th>
<th>Current Location</th>
<th>State</th>
<th>Last Confirmed</th>
</tr>
</thead>
<tbody>
@foreach (var characterState in Model.StoryState.Characters)
{
<tr>
<td>@characterState.CharacterName</td>
<td>@characterState.CurrentLocation</td>
<td><span class="badge @StoryStateBadgeClass(characterState.State)">@characterState.State</span></td>
<td>@characterState.LastConfirmed</td>
</tr>
}
</tbody>
</table>
</div>
}
<h4 class="inspector-subheading">Assets</h4>
@if (!Model.StoryState.Assets.Any())
{
<p class="muted">No project assets found.</p>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Asset</th>
<th>Current Holder / Location</th>
<th>State</th>
<th>Last Confirmed</th>
</tr>
</thead>
<tbody>
@foreach (var assetState in Model.StoryState.Assets)
{
<tr>
<td>@assetState.AssetName</td>
<td>@assetState.CurrentHolderOrLocation</td>
<td><span class="badge @StoryStateBadgeClass(assetState.State)">@assetState.State</span></td>
<td>@assetState.LastConfirmed</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<!-- Planning and workflow -->
<section class="inspector-section writer-workspace-section" data-section-title="Writer Workspace" data-section-id="inspector-writer">
<div class="inspector-section-heading">