Phase 12A: Story State Engine and Scene Inspector Integration for the new Continuity Explorer feature.
This commit is contained in:
parent
d07a11cd06
commit
eafa9927c7
@ -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>();
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user