Add story point context view

This commit is contained in:
Nick Beckley 2026-09-07 22:35:03 +00:00
parent d5e11987c2
commit 10d5a657ad
18 changed files with 845 additions and 19 deletions

View File

@ -7,7 +7,7 @@ The add-in is deliberately small:
- The manifest adds one ribbon button: **Open PlotDirector Companion**.
- The task pane opens the PlotDirector-hosted `/word-companion` route.
- The manifest requests `ReadWriteDocument` so the companion can read Word paragraphs and write hidden PlotDirector anchors back into the manuscript after import.
- The companion does not request document write permissions.
- The companion only writes hidden PlotDirector anchors needed to reconnect manuscript paragraphs to PlotDirector scenes.
## Development

View File

@ -19,6 +19,12 @@ using PlotLine.ViewModels;
var tests = new (string Name, Action Test)[]
{
("Story Point presence, previous appearances and age cross book boundaries", StoryPointTests.PeopleAndChronology),
("Story Point knowledge stops at selected narrative position", StoryPointTests.KnowledgeBoundary),
("Story Point relationships and independent asset state are historical", StoryPointTests.RelationshipsAndAssets),
("Story Point threads and continuity warnings respect future evidence", StoryPointTests.ThreadsAndWarnings),
("Story Point ownership, AJAX and bounded batch loading require no AI", StoryPointTests.OwnershipAndNoAi),
("Story Point narrative tie breakers match canonical order", StoryPointTests.CanonicalTieBreaks),
("Malformed confidence 0. is repaired to 0.0", RepairsTrailingDecimalConfidence),
("Markdown fences are stripped", StripsMarkdownFence),
("Unescaped backslash in evidence is repaired", RepairsUnescapedBackslash),

View File

@ -0,0 +1,168 @@
using System.Reflection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Controllers;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
internal static class StoryPointTests
{
private static void Check(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); }
private static StoryPointSnapshot Snapshot(StoryPointInputs? input = null, int scene = 20)
=> StoryPointContextService.Compose(input ?? Fixture(), scene) ?? throw new Exception("Missing snapshot");
public static void PeopleAndChronology()
{
var point = Snapshot();
Check(point.Characters.Select(x => x.Appearance.CharacterID).SequenceEqual([1, 2]), "Only selected SceneCharacters belong in presence.");
Check(point.Characters[0].PreviousAppearance?.SceneID == 900, "Previous appearance must cross books and exclude selected scene, regardless of IDs.");
Check(point.Characters[1].PreviousAppearance is null, "First appearance has no previous source.");
Check(point.Characters[0].Age.DisplayText == CharacterAgeDisplayService.GetAgeForScene(Fixture().Characters[0], "Exact Date", new DateTime(2001, 2, 5)).DisplayText,
"Age must use existing service and selected date.");
Check(point.Characters[0].Age.DisplayText == "20 years, 1 month", "Exact age is incorrect.");
var input = Fixture(); input.Timeline.Scenes.Single(x => x.SceneID == 20).StartDateTime = null;
Check(Snapshot(input).Characters[0].Age.DisplayText == "About 19", "Undated chronology must use authoritative approximate age.");
Check(Snapshot(input).Characters[1].Age.DisplayText is null, "Unknown age must stay unknown.");
Check(point.PreviousLocationUse?.SceneID == 900, "Previous location use must cross book boundaries.");
}
public static void KnowledgeBoundary()
{
var point = Snapshot();
Check(point.Characters[0].Knowledge.Single().Description == "The gate code", "Prior knowledge must be retained.");
Check(point.Characters[0].Knowledge.Single().KnowledgeStateName == "Knows", "Later knowledge regression must not change earlier state.");
Check(point.Characters[1].Knowledge.Count == 0, "Future knowledge must not leak.");
var later = Snapshot(scene: 1);
Check(later.Characters.Single(x => x.Appearance.CharacterID == 1).Knowledge.Any(x => x.KnowledgeStateName == "Unaware"), "Later knowledge transition must be reflected.");
Check(point.Sources.Keys.ToHashSet().SetEquals([900, 20]), "Snapshot sources must exclude future scenes.");
}
public static void RelationshipsAndAssets()
{
var point = Snapshot();
Check(point.Relationships.Count == 1 && point.Relationships[0].CurrentStateName == "Trusted", "Earlier relationship event must win; future/undated relationships excluded.");
Check(point.Relationships[0].History.All(x => x.SceneID != 1), "Future relationship history must not leak.");
var asset = point.Assets.Single();
Check(asset.Holder == "Ari" && asset.Location == "Harbour" && asset.Status == "Sealed", "Custody, location and status must be independently historical.");
Check(asset.LastConfirmedSceneID == 20, "Latest asset event must stop at selected scene.");
Check(point.Location!.AssetsPresent.Contains("Case"), "Custody must not erase known asset location.");
var initialInput = Fixture();
var undated = initialInput.Relationships.Relationships.Single(x => x.CharacterRelationshipID == 3);
undated.IsInitialRelationship = true; undated.StartSceneID = 1;
Check(Snapshot(initialInput).Relationships.Count == 1, "Future start must override initial flag.");
undated.StartSceneID = null; undated.InitialBookID = 999;
Check(Snapshot(initialInput).Relationships.Count == 1, "Unplaced initial-book boundary must fail closed.");
undated.InitialBookID = null; undated.EndSceneID = 900;
Check(Snapshot(initialInput).Relationships.Count == 1, "Already-ended relationship must not be active.");
var later = Snapshot(scene: 1);
Check(later.Assets.Single(x => x.StoryAssetID == 10).Holder == "Outsider", "Later custody event must take effect only later.");
Check(later.Relationships[0].CurrentStateName == "Hostile", "Later relationship state must take effect.");
}
public static void ThreadsAndWarnings()
{
var point = Snapshot();
Check(point.Threads.Select(x => x.PlotThreadID).ToHashSet().SetEquals([1, 4]), "Active threads include earlier introduction and bounded event introduction; exclude future and already resolved threads.");
Check(point.Threads.Single(x => x.PlotThreadID == 1).State == "Introduced", "Global resolved status must not leak backwards.");
Check(point.Warnings.Any(x => x.CharacterID == 2 && x.WarningType.Contains("Unknown")), "Future location evidence must not suppress current unknown-location warning.");
Check(point.Warnings.All(x => x.SceneID != 1 && x.PreviousSceneId != 1), "Warnings must not contain future evidence.");
Check(Snapshot(scene: 1).Threads.All(x => x.PlotThreadID != 1), "Resolved-at-selected-scene thread is no longer active.");
var input = Fixture();
input.History.ThreadEvents.Single(x => x.PlotThreadID == 4).EventTypeName = "Resolved";
Check(Snapshot(input).Threads.All(x => x.PlotThreadID != 4), "Resolved event must close thread even when resolution field is absent.");
}
public static void OwnershipAndNoAi()
{
var input = Fixture(); int dataCalls = 0;
var access = Proxy<IProjectAccessService>((_, _) => Task.FromResult(false));
var timeline = Proxy<ITimelineRepository>((_, _) => { dataCalls++; return Task.FromResult(input.Timeline); });
var unusedCharacters = Proxy<ICharacterRepository>((_, _) => throw new Exception("Unexpected character query"));
var unusedAssets = Proxy<IAssetRepository>((_, _) => throw new Exception("Unexpected asset query"));
var unusedLocations = Proxy<ILocationRepository>((_, _) => throw new Exception("Unexpected location query"));
var unusedHistory = Proxy<IStoryPointRepository>((_, _) => throw new Exception("Unexpected history query"));
var unusedAcknowledgements = Proxy<IContinuityWarningAcknowledgementRepository>((_, _) => throw new Exception("Unexpected acknowledgement query"));
var service = new StoryPointContextService(access, timeline, unusedCharacters, unusedAssets, unusedLocations, unusedHistory, unusedAcknowledgements);
Check(service.GetAsync(1, 20).Result is null && dataCalls == 0, "Denied project must not load any data.");
access = Proxy<IProjectAccessService>((_, _) => Task.FromResult(true));
service = new StoryPointContextService(access, timeline, unusedCharacters, unusedAssets, unusedLocations, unusedHistory, unusedAcknowledgements);
Check(service.GetAsync(1, 9999).Result is null, "A scene outside the requested project must be rejected before loading history.");
var calls = new List<string>();
var characters = Proxy<ICharacterRepository>((method, _) => { calls.Add(method); return method switch {
nameof(ICharacterRepository.GetTimelineAsync) => Task.FromResult((input.Characters, input.Appearances)),
nameof(ICharacterRepository.GetRelationshipMapAsync) => Task.FromResult(input.Relationships),
_ => throw new Exception("Unexpected per-character query") }; });
var assets = Proxy<IAssetRepository>((method, _) => { calls.Add(method); return Task.FromResult((input.Assets, input.AssetEvents)); });
var locations = Proxy<ILocationRepository>((method, _) => { calls.Add(method); return Task.FromResult(input.Locations); });
var history = Proxy<IStoryPointRepository>((method, _) => { calls.Add(method); return Task.FromResult(input.History); });
var acknowledgements = Proxy<IContinuityWarningAcknowledgementRepository>((method, _) => { calls.Add(method); return Task.FromResult(input.Acknowledgements); });
service = new StoryPointContextService(access, timeline, characters, assets, locations, history, acknowledgements);
Check(service.GetAsync(1, 20).Result?.Snapshot?.Scene.SceneID == 20 && calls.Count == 6, "Page load must compose six history batches with no per-entity calls or AI dependency.");
Check(StoryPointContextService.Compose(input, 9999) is null, "Invalid scene projection must fail closed.");
var controller = new StoryPointController(service) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } };
controller.Request.Headers["X-Requested-With"] = "XMLHttpRequest";
Check(controller.Index(1, 20).Result is PartialViewResult { ViewName: "_Snapshot" }, "AJAX route must render reusable snapshot.");
Check(controller.Index(1, 9999).Result is NotFoundResult, "Controller must reject cross-project/unknown scene.");
Check(controller.Index(0, 20).Result is BadRequestObjectResult, "Missing project must fail.");
}
public static void CanonicalTieBreaks()
{
var input = Fixture();
input.Timeline.Books[0].SortOrder = 1; input.Timeline.Books[1].SortOrder = 1;
Check(StoryStateService.OrderScenes(input.Timeline).Select(x => x.SceneID).SequenceEqual([900, 20, 1]), "BookNumber tie breaker must match NarrativeSceneOrder, not input or ID order.");
Check(Snapshot(input).Relationships.Single().CurrentStateName == "Trusted", "Relationship projection must use the same canonical boundary.");
}
private static T Proxy<T>(Func<string, object?[]?, object?> invoke) where T : class
{ var proxy = DispatchProxy.Create<T, StoryPointProxy>(); ((StoryPointProxy)(object)proxy).Handler = invoke; return proxy; }
private static StoryPointInputs Fixture() => new()
{
Timeline = new TimelineData { Project = new Project { ProjectID = 1 },
Books = [new Book { BookID = 2, BookNumber = 2, SortOrder = 2, BookTitle = "Voyage" }, new Book { BookID = 9, BookNumber = 1, SortOrder = 1, BookTitle = "Departure" }],
Chapters = [new Chapter { ChapterID = 2, BookID = 2, ChapterNumber = 1, SortOrder = 1 }, new Chapter { ChapterID = 9, BookID = 9, ChapterNumber = 1, SortOrder = 1 }],
Scenes = [new Scene { SceneID = 1, ChapterID = 2, SortOrder = 2, SceneNumber = 2, PrimaryLocationID = 5 },
new Scene { SceneID = 20, ChapterID = 2, SortOrder = 1, SceneNumber = 1, PrimaryLocationID = 5, TimeModeName = "Exact Date", StartDateTime = new DateTime(2001, 2, 5) },
new Scene { SceneID = 900, ChapterID = 9, SortOrder = 1, SceneNumber = 1, PrimaryLocationID = 5 }] },
Characters = [new Character { CharacterID = 1, CharacterName = "Ari", BirthDate = new DateTime(1981, 1, 5), AgeAtSeriesStart = 19 }, new Character { CharacterID = 2, CharacterName = "Bo" }],
Appearances = [new SceneCharacter { CharacterID = 1, CharacterName = "Ari", SceneID = 900, LocationID = 5, LocationPath = "Harbour" },
new SceneCharacter { CharacterID = 1, CharacterName = "Ari", SceneID = 20, LocationID = 5, LocationPath = "Harbour" },
new SceneCharacter { CharacterID = 2, CharacterName = "Bo", SceneID = 20 },
new SceneCharacter { CharacterID = 1, CharacterName = "Ari", SceneID = 1, LocationID = 6, LocationPath = "Tower" },
new SceneCharacter { CharacterID = 2, CharacterName = "Bo", SceneID = 1, LocationID = 6, LocationPath = "Tower" }],
Assets = [new StoryAsset { StoryAssetID = 10, AssetName = "Case" }, new StoryAsset { StoryAssetID = 11, AssetName = "Future object" }],
AssetEvents = [new AssetEvent { AssetEventID = 1, StoryAssetID = 10, SceneID = 900, ToStateName = "Sealed" }, new AssetEvent { AssetEventID = 2, StoryAssetID = 10, SceneID = 1, ToStateName = "Opened" }],
Locations = [new LocationItem { LocationID = 5, LocationName = "Harbour", LocationPath = "Harbour" }],
Relationships = new RelationshipMapData { Relationships = [
new CharacterRelationship { CharacterRelationshipID = 1, CharacterAID = 1, CharacterBID = 2, CharacterAName = "Ari", CharacterBName = "Bo" },
new CharacterRelationship { CharacterRelationshipID = 2, CharacterAID = 1, CharacterBID = 3 },
new CharacterRelationship { CharacterRelationshipID = 3, CharacterAID = 1, CharacterBID = 4 }],
RelationshipEvents = [new RelationshipEvent { CharacterRelationshipID = 1, SceneID = 900, RelationshipStateName = "Trusted" },
new RelationshipEvent { CharacterRelationshipID = 1, SceneID = 1, RelationshipStateName = "Hostile" },
new RelationshipEvent { CharacterRelationshipID = 2, SceneID = 1, RelationshipStateName = "Married" }] },
History = new StoryPointHistory {
Knowledge = [new CharacterKnowledgeItem { CharacterKnowledgeID = 1, CharacterID = 1, SceneID = 900, Description = "The gate code", KnowledgeStateName = "Knows" },
new CharacterKnowledgeItem { CharacterKnowledgeID = 2, CharacterID = 1, SceneID = 1, Description = "The gate code", KnowledgeStateName = "Unaware" },
new CharacterKnowledgeItem { CharacterKnowledgeID = 3, CharacterID = 2, SceneID = 1, Description = "Future secret", KnowledgeStateName = "Knows" }],
AssetLocations = [new SceneAssetLocation { StoryAssetID = 10, SceneID = 900, LocationID = 5, LocationPath = "Harbour" },
new SceneAssetLocation { StoryAssetID = 10, SceneID = 1, LocationID = 6, LocationPath = "Tower" }],
Custody = [new AssetCustodyEvent { AssetCustodyEventID = 1, StoryAssetID = 10, SceneID = 20, CustodianSummary = "Ari" },
new AssetCustodyEvent { AssetCustodyEventID = 2, StoryAssetID = 10, SceneID = 1, CustodianSummary = "Outsider" }],
Custodians = [new AssetCustodyCharacter { AssetCustodyEventID = 1, StoryAssetID = 10, SceneID = 20, CharacterID = 1 },
new AssetCustodyCharacter { AssetCustodyEventID = 2, StoryAssetID = 10, SceneID = 1, CharacterID = 3 }],
Threads = [new PlotThread { PlotThreadID = 1, ThreadTitle = "Open question", ThreadTypeName = "Question", IntroducedSceneID = 900, ActualResolutionSceneID = 1, ThreadStatusName = "Resolved" },
new PlotThread { PlotThreadID = 2, ThreadTitle = "Future", IntroducedSceneID = 1 },
new PlotThread { PlotThreadID = 3, ThreadTitle = "Closed", IntroducedSceneID = 900, ActualResolutionSceneID = 900 },
new PlotThread { PlotThreadID = 4, ThreadTitle = "Clue", ThreadTypeName = "Clue" }],
ThreadEvents = [new ThreadEvent { PlotThreadID = 4, SceneID = 20, EventTypeName = "Introduced" }] }
};
}
public class StoryPointProxy : DispatchProxy
{
public Func<string, object?[]?, object?> Handler { get; set; } = null!;
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => Handler(targetMethod!.Name, args);
}

View File

@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Services;
namespace PlotLine.Controllers;
[Authorize]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class StoryPointController(IStoryPointContextService context) : Controller
{
[HttpGet]
public async Task<IActionResult> Index(int projectId, int? sceneId)
{
if (projectId <= 0) return BadRequest("ProjectID is required.");
var model = await context.GetAsync(projectId, sceneId);
if (model is null) return NotFound();
return string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase)
? PartialView("_Snapshot", model.Snapshot) : View(model);
}
}

View File

@ -0,0 +1,39 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public sealed class StoryPointHistory
{
public IReadOnlyList<CharacterKnowledgeItem> Knowledge { get; init; } = [];
public IReadOnlyList<SceneAssetLocation> AssetLocations { get; init; } = [];
public IReadOnlyList<AssetCustodyEvent> Custody { get; init; } = [];
public IReadOnlyList<AssetCustodyCharacter> Custodians { get; init; } = [];
public IReadOnlyList<PlotThread> Threads { get; init; } = [];
public IReadOnlyList<ThreadEvent> ThreadEvents { get; init; } = [];
}
public interface IStoryPointRepository
{
Task<StoryPointHistory> GetHistoryAsync(int projectId, int sceneId);
}
public sealed class StoryPointRepository(ISqlConnectionFactory connections) : IStoryPointRepository
{
public async Task<StoryPointHistory> GetHistoryAsync(int projectId, int sceneId)
{
using var connection = connections.CreateConnection();
using var rows = await connection.QueryMultipleAsync("dbo.StoryPoint_GetHistory",
new { ProjectID = projectId, SceneID = sceneId }, commandType: CommandType.StoredProcedure);
return new StoryPointHistory
{
Knowledge = (await rows.ReadAsync<CharacterKnowledgeItem>()).ToList(),
AssetLocations = (await rows.ReadAsync<SceneAssetLocation>()).ToList(),
Custody = (await rows.ReadAsync<AssetCustodyEvent>()).ToList(),
Custodians = (await rows.ReadAsync<AssetCustodyCharacter>()).ToList(),
Threads = (await rows.ReadAsync<PlotThread>()).ToList(),
ThreadEvents = (await rows.ReadAsync<ThreadEvent>()).ToList()
};
}
}

View File

@ -176,6 +176,8 @@ public class Program
builder.Services.AddScoped<IStoryContextService, StoryContextService>();
builder.Services.AddScoped<ITimelineService, TimelineService>();
builder.Services.AddScoped<IStoryCalendarService, StoryCalendarService>();
builder.Services.AddScoped<IStoryPointRepository, StoryPointRepository>();
builder.Services.AddScoped<IStoryPointContextService, StoryPointContextService>();
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>();
builder.Services.AddScoped<IPlotService, PlotService>();

View File

@ -3363,7 +3363,7 @@ public sealed class TimelineService(
}
}
public sealed class StoryStateService(
public sealed partial class StoryStateService(
IProjectRepository projects,
IBookRepository books,
IChapterRepository chapters,
@ -3762,15 +3762,18 @@ public sealed class StoryStateService(
BuildSceneContexts(timeline));
}
private static IEnumerable<Scene> OrderScenes(TimelineData timeline)
public 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 booksById = timeline.Books.ToDictionary(x => x.BookID);
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 => booksById.GetValueOrDefault(chaptersById.GetValueOrDefault(scene.ChapterID)?.BookID ?? 0)?.BookNumber)
.ThenBy(scene => chapterSortOrders.GetValueOrDefault(scene.ChapterID))
.ThenBy(scene => chaptersById.GetValueOrDefault(scene.ChapterID)?.ChapterNumber)
.ThenBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID);
@ -3885,6 +3888,13 @@ public sealed class StoryStateService(
{
var assignments = BuildAssetAssignments(assetEvents, assetLocations, assetCustody, allowedSceneIds);
var locationsByAsset = assetLocations.Where(x => allowedSceneIds.Contains(x.SceneID)).GroupBy(x => x.StoryAssetID)
.ToDictionary(x => x.Key, x => x.OrderBy(y => sceneIndexes[y.SceneID]).ThenBy(y => y.UpdatedDate).ThenBy(y => y.SceneAssetLocationID).Last());
var custodyByAsset = assetCustody.Where(x => allowedSceneIds.Contains(x.SceneID)).GroupBy(x => x.StoryAssetID)
.ToDictionary(x => x.Key, x => x.OrderBy(y => sceneIndexes[y.SceneID]).ThenBy(y => y.UpdatedDate).ThenBy(y => y.AssetCustodyEventID).Last());
var eventsByAsset = assetEvents.Where(x => allowedSceneIds.Contains(x.SceneID)).GroupBy(x => x.StoryAssetID)
.ToDictionary(x => x.Key, x => x.OrderBy(y => sceneIndexes[y.SceneID]).ThenBy(y => y.UpdatedDate).ThenBy(y => y.AssetEventID).ToList());
return projectAssets
.OrderByDescending(x => x.Importance)
.ThenBy(x => x.AssetName)
@ -3897,7 +3907,13 @@ public sealed class StoryStateService(
.ThenByDescending(x => x.UpdatedDate)
.FirstOrDefault();
if (latest is null)
var location = locationsByAsset.GetValueOrDefault(asset.StoryAssetID);
var custody = custodyByAsset.GetValueOrDefault(asset.StoryAssetID);
var events = eventsByAsset.GetValueOrDefault(asset.StoryAssetID, []);
var lastEvent = events.LastOrDefault();
var lastSceneId = new int?[] { latest?.SceneID, lastEvent?.SceneID, custody?.SceneID, location?.SceneID }
.Where(x => x.HasValue).OrderBy(x => sceneIndexes[x!.Value]).LastOrDefault();
if (lastSceneId is null)
{
return new AssetStoryStateViewModel
{
@ -3910,11 +3926,16 @@ public sealed class StoryStateService(
{
StoryAssetID = asset.StoryAssetID,
AssetName = asset.AssetName,
LocationID = latest.LocationID,
CurrentHolderOrLocation = latest.Label,
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
LastConfirmedSceneID = latest.SceneID,
LastConfirmed = scenesById.TryGetValue(latest.SceneID, out var scene)
LocationID = location?.LocationID,
Location = location?.LocationPath ?? "Unknown",
Holder = custody?.CustodianSummary ?? "Unknown",
CustodyEventID = custody?.AssetCustodyEventID,
Status = events.LastOrDefault(x => !string.IsNullOrWhiteSpace(x.ToStateName))?.ToStateName ?? "Unknown",
LatestEvent = custody?.SceneID == lastSceneId ? custody.CustodyEventTypeName : lastEvent?.SceneID == lastSceneId ? lastEvent.EventTitle : "Location recorded",
CurrentHolderOrLocation = latest?.Label ?? "Unknown",
State = lastSceneId == currentSceneId ? "Explicit" : "Inferred",
LastConfirmedSceneID = lastSceneId,
LastConfirmed = scenesById.TryGetValue(lastSceneId.Value, out var scene)
? SceneLabel(scene.SceneNumber, scene.SceneTitle)
: "Unknown"
};
@ -4128,6 +4149,16 @@ public sealed class StoryStateService(
IReadOnlyList<Character> projectCharacters,
IReadOnlySet<int> enabledCharacterIds,
RangeContext context)
{
var items = new List<CharacterKnowledgeItem>();
foreach (var character in projectCharacters.Where(x => !enabledCharacterIds.Any() || enabledCharacterIds.Contains(x.CharacterID)))
items.AddRange(await characters.ListKnowledgeByCharacterAsync(character.CharacterID));
return BuildKnowledgeRegressionWarnings(projectCharacters, enabledCharacterIds, context, items);
}
private static List<ContinuityWarningViewModel> BuildKnowledgeRegressionWarnings(
IReadOnlyList<Character> projectCharacters, IReadOnlySet<int> enabledCharacterIds,
RangeContext context, IReadOnlyList<CharacterKnowledgeItem> items)
{
var warnings = new List<ContinuityWarningViewModel>();
var charactersToCheck = projectCharacters
@ -4136,7 +4167,7 @@ public sealed class StoryStateService(
foreach (var character in charactersToCheck)
{
var knowledgeEvents = (await characters.ListKnowledgeByCharacterAsync(character.CharacterID))
var knowledgeEvents = items.Where(x => x.CharacterID == character.CharacterID)
.Where(x => context.SceneIndexes.ContainsKey(x.SceneID))
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, int.MaxValue))
.ThenBy(x => x.CreatedDate)
@ -5941,13 +5972,32 @@ public sealed class RelationshipMapService(IProjectRepository projects, ICharact
};
}
private static RelationshipMapRelationshipViewModel? BuildRelationship(CharacterRelationship relationship, IReadOnlyList<RelationshipEvent> events, IReadOnlyDictionary<int, int> sceneOrder, int maxSceneIndex)
public static IReadOnlyList<RelationshipMapRelationshipViewModel> BuildAtScene(
RelationshipMapData data, IReadOnlyDictionary<int, int> sceneOrder, int sceneId)
{
if (!sceneOrder.TryGetValue(sceneId, out var maximum)) return [];
var events = data.RelationshipEvents.GroupBy(x => x.CharacterRelationshipID)
.ToDictionary(x => x.Key, x => (IReadOnlyList<RelationshipEvent>)x.ToList());
var chapters = data.Chapters.ToDictionary(x => x.ChapterID);
var firstSceneByBook = data.Scenes.Where(x => sceneOrder.ContainsKey(x.SceneID) && chapters.ContainsKey(x.ChapterID))
.GroupBy(x => chapters[x.ChapterID].BookID).ToDictionary(x => x.Key, x => x.Min(y => sceneOrder[y.SceneID]));
return data.Relationships
.Where(x => !x.IsArchived
&& (!x.StartSceneID.HasValue || SceneIndex(sceneOrder, x.StartSceneID.Value) <= maximum)
&& (!x.EndSceneID.HasValue || SceneIndex(sceneOrder, x.EndSceneID.Value) > maximum)
&& (!x.InitialBookID.HasValue || firstSceneByBook.GetValueOrDefault(x.InitialBookID.Value, int.MaxValue) <= maximum))
.Where(x => x.IsInitialRelationship || x.StartSceneID.HasValue || events.ContainsKey(x.CharacterRelationshipID))
.Select(x => BuildRelationship(x, events.GetValueOrDefault(x.CharacterRelationshipID, []), sceneOrder, maximum, x.StartSceneID.HasValue))
.OfType<RelationshipMapRelationshipViewModel>().ToList();
}
private static RelationshipMapRelationshipViewModel? BuildRelationship(CharacterRelationship relationship, IReadOnlyList<RelationshipEvent> events, IReadOnlyDictionary<int, int> sceneOrder, int maxSceneIndex, bool hasDatedInitialState = false)
{
var visibleEvents = maxSceneIndex < 0
? []
: events.Where(x => SceneIndex(sceneOrder, x.SceneID) <= maxSceneIndex).ToList();
var hasInitialState = relationship.IsInitialRelationship || !events.Any();
var hasInitialState = hasDatedInitialState || relationship.IsInitialRelationship || !events.Any();
if (!hasInitialState && visibleEvents.Count == 0)
{
return null;
@ -6002,13 +6052,7 @@ public sealed class RelationshipMapService(IProjectRepository projects, ICharact
private static IReadOnlyDictionary<int, int> BuildSceneOrder(RelationshipMapData data)
{
var bookSort = data.Books.ToDictionary(x => x.BookID, x => x.SortOrder);
var chapterSort = data.Chapters.ToDictionary(x => x.ChapterID, x => x.SortOrder);
return data.Scenes
.OrderBy(x => bookSort.TryGetValue(data.Chapters.FirstOrDefault(c => c.ChapterID == x.ChapterID)?.BookID ?? 0, out var b) ? b : 0)
.ThenBy(x => chapterSort.TryGetValue(x.ChapterID, out var c) ? c : 0)
.ThenBy(x => x.SortOrder)
.ThenBy(x => x.SceneNumber)
return StoryStateService.OrderScenes(new TimelineData { Books = data.Books, Chapters = data.Chapters, Scenes = data.Scenes })
.Select((scene, index) => new { scene.SceneID, Index = index })
.ToDictionary(x => x.SceneID, x => x.Index);
}

View File

@ -0,0 +1,132 @@
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public sealed class StoryPointInputs
{
public TimelineData Timeline { get; init; } = new();
public IReadOnlyList<Character> Characters { get; init; } = [];
public IReadOnlyList<SceneCharacter> Appearances { get; init; } = [];
public IReadOnlyList<StoryAsset> Assets { get; init; } = [];
public IReadOnlyList<AssetEvent> AssetEvents { get; init; } = [];
public IReadOnlyList<LocationItem> Locations { get; init; } = [];
public RelationshipMapData Relationships { get; init; } = new();
public StoryPointHistory History { get; init; } = new();
public IReadOnlyList<ContinuityWarningAcknowledgement> Acknowledgements { get; init; } = [];
}
public interface IStoryPointContextService
{
Task<StoryPointViewModel?> GetAsync(int projectId, int? sceneId);
}
public sealed class StoryPointContextService(IProjectAccessService access, ITimelineRepository timelineRepository,
ICharacterRepository characters, IAssetRepository assets, ILocationRepository locations,
IStoryPointRepository history, IContinuityWarningAcknowledgementRepository acknowledgements) : IStoryPointContextService
{
public async Task<StoryPointViewModel?> GetAsync(int projectId, int? sceneId)
{
if (!await access.CanAccessProjectAsync(projectId)) return null;
var timeline = await timelineRepository.GetByProjectAsync(projectId, null, false, [], false);
if (timeline.Project is null) return null;
var ordered = StoryStateService.OrderScenes(timeline).ToList();
if (sceneId.HasValue && ordered.All(x => x.SceneID != sceneId)) return null;
sceneId ??= ordered.FirstOrDefault()?.SceneID;
StoryPointSnapshot? snapshot = null;
if (sceneId.HasValue)
{
// Each repository uses its own connection; batch count is independent of scene/entity count.
var peopleTask = characters.GetTimelineAsync(projectId, null);
var assetsTask = assets.GetTimelineAsync(projectId, null);
var locationsTask = locations.ListByProjectAsync(projectId);
var relationshipsTask = characters.GetRelationshipMapAsync(projectId);
var historyTask = history.GetHistoryAsync(projectId, sceneId.Value);
var acknowledgementsTask = acknowledgements.ListByProjectAsync(projectId);
await Task.WhenAll(peopleTask, assetsTask, locationsTask, relationshipsTask, historyTask, acknowledgementsTask);
snapshot = Compose(new StoryPointInputs
{
Timeline = timeline, Characters = peopleTask.Result.Characters, Appearances = peopleTask.Result.Appearances,
Assets = assetsTask.Result.Assets, AssetEvents = assetsTask.Result.Events,
Locations = locationsTask.Result, Relationships = relationshipsTask.Result,
History = historyTask.Result, Acknowledgements = acknowledgementsTask.Result
}, sceneId.Value);
}
return new StoryPointViewModel { Project = timeline.Project, Books = timeline.Books.OrderBy(x => x.SortOrder).ThenBy(x => x.BookNumber).ToList(),
Chapters = timeline.Chapters.OrderBy(x => x.SortOrder).ThenBy(x => x.ChapterNumber).ToList(), Scenes = ordered, Snapshot = snapshot };
}
public static StoryPointSnapshot? Compose(StoryPointInputs input, int sceneId)
{
var ordered = StoryStateService.OrderScenes(input.Timeline).ToList();
var maximum = ordered.FindIndex(x => x.SceneID == sceneId);
if (maximum < 0 || input.Timeline.Project is null) return null;
var scene = ordered[maximum];
var chapter = input.Timeline.Chapters.Single(x => x.ChapterID == scene.ChapterID);
var book = input.Timeline.Books.Single(x => x.BookID == chapter.BookID);
var indexes = ordered.Select((x, i) => (x.SceneID, i)).ToDictionary(x => x.SceneID, x => x.i);
var allowed = ordered.Take(maximum + 1).Select(x => x.SceneID).ToHashSet();
var chaptersById = input.Timeline.Chapters.ToDictionary(x => x.ChapterID);
var booksById = input.Timeline.Books.ToDictionary(x => x.BookID);
var sources = ordered.Take(maximum + 1).ToDictionary(x => x.SceneID, x =>
{
var ch = chaptersById[x.ChapterID];
return new StoryPointSource(x.SceneID, $"{booksById[ch.BookID].BookDisplayTitle} · Ch {ch.ChapterNumber:g} · Scene {x.SceneNumber:g}");
});
var present = input.Appearances.Where(x => x.SceneID == sceneId).GroupBy(x => x.CharacterID)
.Select(x => x.OrderByDescending(y => y.UpdatedDate).First()).OrderBy(x => x.CharacterName).ToList();
var presentIds = present.Select(x => x.CharacterID).ToHashSet();
var state = StoryStateService.BuildAtScene(input, sceneId);
var knowledge = StoryStateService.KnowledgeAtScene(input.History.Knowledge, indexes, sceneId).ToLookup(x => x.CharacterID);
var previous = input.Appearances.Where(x => allowed.Contains(x.SceneID) && x.SceneID != sceneId)
.GroupBy(x => x.CharacterID).ToDictionary(x => x.Key, x => x.OrderBy(y => indexes[y.SceneID]).Last().SceneID);
var characterModels = input.Characters.ToDictionary(x => x.CharacterID);
var people = present.Select(x => new StoryPointCharacter(x,
characterModels.TryGetValue(x.CharacterID, out var character)
? CharacterAgeDisplayService.GetAgeForScene(character, scene.TimeModeName, scene.StartDateTime)
: CharacterAgeDisplayService.GetAgeForScene(x, scene.TimeModeName, scene.StartDateTime),
state.Characters.FirstOrDefault(y => y.CharacterID == x.CharacterID),
previous.TryGetValue(x.CharacterID, out var previousId) ? sources[previousId] : null, knowledge[x.CharacterID].ToList())).ToList();
var custodyIds = input.History.Custodians.Where(x => x.CharacterID.HasValue && presentIds.Contains(x.CharacterID.Value))
.Select(x => x.AssetCustodyEventID).ToHashSet();
var pointAssets = state.Assets.Where(x => x.LastConfirmedSceneID == sceneId
|| (scene.PrimaryLocationID.HasValue && x.LocationID == scene.PrimaryLocationID)
|| (x.CustodyEventID.HasValue && custodyIds.Contains(x.CustodyEventID.Value))).ToList();
var relevantCharacters = presentIds.Concat(state.Characters.Where(x => scene.PrimaryLocationID.HasValue && x.LocationID == scene.PrimaryLocationID).Select(x => x.CharacterID)).ToHashSet();
var location = scene.PrimaryLocationID.HasValue
? state.Locations.FirstOrDefault(x => x.LocationID == scene.PrimaryLocationID) ?? input.Locations.Where(x => x.LocationID == scene.PrimaryLocationID)
.Select(x => new LocationStoryStateViewModel { LocationID = x.LocationID, LocationName = x.LocationName, LocationPath = x.LocationPath }).FirstOrDefault()
: null;
var lastLocationScene = ordered.Take(maximum).LastOrDefault(x => scene.PrimaryLocationID.HasValue && x.PrimaryLocationID == scene.PrimaryLocationID);
return new StoryPointSnapshot
{
ProjectID = input.Timeline.Project.ProjectID, Book = book, Chapter = chapter, Scene = scene, Characters = people,
Relationships = RelationshipMapService.BuildAtScene(input.Relationships, indexes, sceneId)
.Where(x => presentIds.Contains(x.CharacterAID) || presentIds.Contains(x.CharacterBID)).ToList(),
Assets = pointAssets, Location = location, PreviousLocationUse = lastLocationScene is null ? null : sources[lastLocationScene.SceneID],
Threads = BuildThreads(input.History, allowed, indexes, sources),
Warnings = StoryStateService.WarningsAtScene(input, sceneId, relevantCharacters, pointAssets.Select(x => x.StoryAssetID).ToHashSet()), Sources = sources
};
}
private static IReadOnlyList<StoryPointThread> BuildThreads(StoryPointHistory history, HashSet<int> allowed,
IReadOnlyDictionary<int, int> indexes, IReadOnlyDictionary<int, StoryPointSource> sources)
{
var events = history.ThreadEvents.Where(x => allowed.Contains(x.SceneID)).ToLookup(x => x.PlotThreadID);
var result = new List<StoryPointThread>();
foreach (var thread in history.Threads.Where(x => !x.IsArchived))
{
var threadEvents = events[thread.PlotThreadID].OrderBy(x => indexes[x.SceneID]).ThenBy(x => x.ThreadEventID).ToList();
if (thread.IntroducedSceneID.HasValue ? !allowed.Contains(thread.IntroducedSceneID.Value) : threadEvents.Count == 0) continue;
if (thread.ActualResolutionSceneID.HasValue && allowed.Contains(thread.ActualResolutionSceneID.Value)) continue;
var latestLifecycle = threadEvents.LastOrDefault(x => x.EventTypeName is "Resolved" or "Payoff" or "Introduced");
if (latestLifecycle?.EventTypeName is "Resolved" or "Payoff") continue;
var last = threadEvents.LastOrDefault();
var sourceId = last?.SceneID ?? thread.IntroducedSceneID;
result.Add(new StoryPointThread(thread.PlotThreadID, thread.ThreadTitle, thread.ThreadTypeName, thread.PlotLineName,
sourceId.HasValue ? sources.GetValueOrDefault(sourceId.Value) : null, last?.EventTypeName ?? "Introduced"));
}
return result.OrderBy(x => x.Title).ToList();
}
}

View File

@ -0,0 +1,65 @@
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
// Read projections shared with Continuity Explorer, without its per-entity database adapters.
public sealed partial class StoryStateService
{
public static StoryStateViewModel BuildAtScene(StoryPointInputs input, int sceneId)
{
var ordered = OrderScenes(input.Timeline).ToList();
var indexes = BuildSceneIndexes(ordered);
if (!indexes.TryGetValue(sceneId, out var maximum)) return new();
var allowed = ordered.Take(maximum + 1).Select(x => x.SceneID).ToHashSet();
var state = new StoryStateViewModel
{
Characters = BuildCharacterState(input.Characters, input.Appearances, sceneId, allowed, indexes),
Assets = BuildAssetState(input.Assets, input.AssetEvents, input.History.AssetLocations, input.History.Custody,
sceneId, allowed, indexes, ordered.ToDictionary(x => x.SceneID))
};
state.Locations = BuildLocationState(input.Locations, state);
return state;
}
public static IReadOnlyList<CharacterKnowledgeItem> KnowledgeAtScene(
IEnumerable<CharacterKnowledgeItem> items, IReadOnlyDictionary<int, int> indexes, int sceneId)
{
if (!indexes.TryGetValue(sceneId, out var maximum)) return [];
return items.Where(x => indexes.TryGetValue(x.SceneID, out var index) && index <= maximum)
.GroupBy(x => (x.CharacterID, Identity: KnowledgeIdentity(x)))
.Select(x => x.OrderBy(y => indexes[y.SceneID]).ThenBy(y => y.CreatedDate).ThenBy(y => y.CharacterKnowledgeID).Last())
.ToList();
}
public static IReadOnlyList<ContinuityWarningViewModel> WarningsAtScene(
StoryPointInputs input, int sceneId, IReadOnlySet<int> characterIds, IReadOnlySet<int> assetIds)
{
var ordered = OrderScenes(input.Timeline).ToList();
var maximum = ordered.FindIndex(x => x.SceneID == sceneId);
if (maximum < 0) return [];
// Bound inputs before invoking any warning algorithm: later evidence must not suppress earlier warnings.
ordered = ordered.Take(maximum + 1).ToList();
var allowed = ordered.Select(x => x.SceneID).ToHashSet();
var context = new RangeContext(ordered, ordered, allowed, BuildSceneIndexes(ordered),
ordered.ToDictionary(x => x.SceneID, x => SceneLabel(x.SceneNumber, x.SceneTitle)), BuildSceneContexts(input.Timeline));
var appearances = input.Appearances.Where(x => allowed.Contains(x.SceneID)).ToList();
var locations = input.History.AssetLocations.Where(x => allowed.Contains(x.SceneID)).ToList();
var custodians = input.History.Custodians.Where(x => allowed.Contains(x.SceneID)).ToList();
var warnings = new List<ContinuityWarningViewModel>();
warnings.AddRange(BuildMultipleCharacterLocationWarnings(appearances, characterIds, context));
warnings.AddRange(BuildImpossibleMovementWarnings(appearances, characterIds, context));
warnings.AddRange(BuildUnknownCharacterLocationWarnings(input.Characters, appearances, characterIds, context));
warnings.AddRange(BuildKnowledgeRegressionWarnings(input.Characters, characterIds, context,
input.History.Knowledge.Where(x => allowed.Contains(x.SceneID)).ToList()));
warnings.AddRange(BuildAssetLocationConflictWarnings(input.Assets, locations, custodians, assetIds, context));
warnings.AddRange(BuildUnknownAssetLocationWarnings(input.Assets, input.AssetEvents.Where(x => allowed.Contains(x.SceneID)).ToList(),
locations, custodians, assetIds, context));
ApplyAcknowledgements(warnings, input.Acknowledgements);
return warnings.Where(x => !x.IsAcknowledged && x.SceneID.HasValue && allowed.Contains(x.SceneID.Value)
&& (x.SceneID == sceneId || (x.CharacterID.HasValue && characterIds.Contains(x.CharacterID.Value))
|| (x.AssetID.HasValue && assetIds.Contains(x.AssetID.Value))))
.OrderBy(x => WarningSeveritySort(x.Severity)).ThenBy(x => x.Message).ToList();
}
}

View File

@ -0,0 +1,66 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
-- Read-only composition inputs. The canonical view supplies the project-wide boundary.
CREATE OR ALTER PROCEDURE dbo.StoryPoint_GetHistory
@ProjectID int,
@SceneID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @MaxIndex bigint = (SELECT GlobalSceneIndex FROM dbo.NarrativeSceneOrder WHERE ProjectID = @ProjectID AND SceneID = @SceneID);
IF @MaxIndex IS NULL THROW 50001, 'Scene does not belong to this project narrative.', 1;
SELECT SceneID INTO #Scope FROM dbo.NarrativeSceneOrder WHERE ProjectID = @ProjectID AND GlobalSceneIndex <= @MaxIndex;
CREATE UNIQUE CLUSTERED INDEX IX_Scope ON #Scope(SceneID);
SELECT ck.*, ch.CharacterName, ks.StateName AS KnowledgeStateName, sa.AssetName, pt.ThreadTitle
FROM dbo.CharacterKnowledge ck
INNER JOIN #Scope n ON n.SceneID = ck.SceneID
INNER JOIN dbo.Characters ch ON ch.CharacterID = ck.CharacterID AND ch.ProjectID = @ProjectID AND ch.IsArchived = 0
INNER JOIN dbo.KnowledgeStates ks ON ks.KnowledgeStateID = ck.KnowledgeStateID
LEFT JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ck.StoryAssetID AND sa.ProjectID = @ProjectID
LEFT JOIN dbo.PlotThreads pt ON pt.PlotThreadID = ck.PlotThreadID;
SELECT sal.*, sa.AssetName, lp.LocationName, lp.LocationPath
FROM dbo.SceneAssetLocations sal
INNER JOIN #Scope n ON n.SceneID = sal.SceneID
INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = sal.StoryAssetID AND sa.ProjectID = @ProjectID AND sa.IsArchived = 0
INNER JOIN dbo.LocationPaths lp ON lp.LocationID = sal.LocationID;
SELECT ace.*, sa.AssetName, acet.TypeName AS CustodyEventTypeName,
holders.CustodianSummary
FROM dbo.AssetCustodyEvents ace
INNER JOIN #Scope n ON n.SceneID = ace.SceneID
INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ace.StoryAssetID AND sa.ProjectID = @ProjectID AND sa.IsArchived = 0
INNER JOIN dbo.AssetCustodyEventTypes acet ON acet.AssetCustodyEventTypeID = ace.AssetCustodyEventTypeID
OUTER APPLY (SELECT STRING_AGG(CONCAT(COALESCE(ch.CharacterName, aec.CharacterNameText), N' (', cr.RoleName, N')'), N', ') AS CustodianSummary
FROM dbo.AssetCustodyEventCharacters aec
LEFT JOIN dbo.Characters ch ON ch.CharacterID = aec.CharacterID
LEFT JOIN dbo.CustodyRoles cr ON cr.CustodyRoleID = aec.CustodyRoleID
WHERE aec.AssetCustodyEventID = ace.AssetCustodyEventID) holders;
SELECT ace.AssetCustodyEventID, ace.StoryAssetID, ace.SceneID, aec.CharacterID,
ch.CharacterName, aec.CharacterNameText, cr.RoleName, ace.UpdatedDate
FROM dbo.AssetCustodyEvents ace
INNER JOIN #Scope n ON n.SceneID = ace.SceneID
INNER JOIN dbo.AssetCustodyEventCharacters aec ON aec.AssetCustodyEventID = ace.AssetCustodyEventID
LEFT JOIN dbo.Characters ch ON ch.CharacterID = aec.CharacterID
LEFT JOIN dbo.CustodyRoles cr ON cr.CustodyRoleID = aec.CustodyRoleID;
SELECT pt.*, pl.ProjectID, pl.PlotLineName, tt.TypeName AS ThreadTypeName
FROM dbo.PlotThreads pt
INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID
INNER JOIN dbo.ThreadTypes tt ON tt.ThreadTypeID = pt.ThreadTypeID
WHERE pl.ProjectID = @ProjectID AND pl.IsArchived = 0 AND pt.IsArchived = 0
AND (EXISTS (SELECT 1 FROM #Scope WHERE SceneID = pt.IntroducedSceneID)
OR (pt.IntroducedSceneID IS NULL AND EXISTS (SELECT 1 FROM dbo.ThreadEvents te INNER JOIN #Scope n ON n.SceneID = te.SceneID WHERE te.PlotThreadID = pt.PlotThreadID)));
SELECT te.*, tet.TypeName AS EventTypeName
FROM dbo.ThreadEvents te
INNER JOIN #Scope n ON n.SceneID = te.SceneID
INNER JOIN dbo.PlotThreads pt ON pt.PlotThreadID = te.PlotThreadID AND pt.IsArchived = 0
INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID AND pl.ProjectID = @ProjectID AND pl.IsArchived = 0
INNER JOIN dbo.ThreadEventTypes tet ON tet.ThreadEventTypeID = te.EventTypeID;
END;
GO

View File

@ -675,6 +675,11 @@ public sealed class CharacterStoryStateViewModel
public sealed class AssetStoryStateViewModel
{
public string Holder { get; set; } = "Unknown";
public string Location { get; set; } = "Unknown";
public string Status { get; set; } = "Unknown";
public int? CustodyEventID { get; set; }
public string? LatestEvent { get; set; }
public int StoryAssetID { get; set; }
public string AssetName { get; set; } = string.Empty;
public int? LocationID { get; set; }

View File

@ -0,0 +1,34 @@
using PlotLine.Models;
using PlotLine.Services;
namespace PlotLine.ViewModels;
public sealed class StoryPointViewModel
{
public Project Project { get; init; } = new();
public IReadOnlyList<Book> Books { get; init; } = [];
public IReadOnlyList<Chapter> Chapters { get; init; } = [];
public IReadOnlyList<Scene> Scenes { get; init; } = [];
public StoryPointSnapshot? Snapshot { get; init; }
}
public sealed class StoryPointSnapshot
{
public int ProjectID { get; init; }
public Book Book { get; init; } = new();
public Chapter Chapter { get; init; } = new();
public Scene Scene { get; init; } = new();
public IReadOnlyList<StoryPointCharacter> Characters { get; init; } = [];
public IReadOnlyList<RelationshipMapRelationshipViewModel> Relationships { get; init; } = [];
public IReadOnlyList<AssetStoryStateViewModel> Assets { get; init; } = [];
public LocationStoryStateViewModel? Location { get; init; }
public StoryPointSource? PreviousLocationUse { get; init; }
public IReadOnlyList<StoryPointThread> Threads { get; init; } = [];
public IReadOnlyList<ContinuityWarningViewModel> Warnings { get; init; } = [];
public IReadOnlyDictionary<int, StoryPointSource> Sources { get; init; } = new Dictionary<int, StoryPointSource>();
}
public sealed record StoryPointSource(int SceneID, string Label);
public sealed record StoryPointCharacter(SceneCharacter Appearance, CharacterAgeDisplayResult Age,
CharacterStoryStateViewModel? State, StoryPointSource? PreviousAppearance, IReadOnlyList<CharacterKnowledgeItem> Knowledge);
public sealed record StoryPointThread(int PlotThreadID, string Title, string Type, string PlotLineName, StoryPointSource? Source, string State);

View File

@ -13,6 +13,7 @@
<a class="pw-project-nav__link @(ActiveClass("Overview"))" asp-controller="Projects" asp-action="Details" asp-route-id="@projectId">Overview</a>
<a class="pw-project-nav__link @(ActiveClass("Timeline"))" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@projectId">Timeline</a>
<a class="pw-project-nav__link @(ActiveClass("Calendar"))" asp-controller="StoryCalendar" asp-action="Index" asp-route-projectId="@projectId">Calendar</a>
<a class="pw-project-nav__link @(ActiveClass("At This Point"))" asp-controller="StoryPoint" asp-action="Index" asp-route-projectId="@projectId">At This Point</a>
<a class="pw-project-nav__link @(ActiveClass("Characters"))" asp-controller="Characters" asp-action="Index" asp-route-projectId="@projectId">Characters</a>
<a class="pw-project-nav__link @(ActiveClass("Names"))" asp-controller="Names" asp-action="Index" asp-route-projectId="@projectId">Names</a>
<a class="pw-project-nav__link @(ActiveClass("Relationships"))" asp-controller="RelationshipMap" asp-action="Index" asp-route-projectId="@projectId">Relationships</a>

View File

@ -0,0 +1,30 @@
@model StoryPointViewModel
@{
ViewData["Title"] = $"At This Point - {Model.Project.ProjectName}";
ViewData["ProjectSection"] = "At This Point";
}
<link rel="stylesheet" href="~/css/story-point.css" asp-append-version="true" />
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<span>At This Point</span>
</nav>
<partial name="_ProjectSectionNav" model="Model.Project" />
<div class="page-heading"><div><p class="eyebrow">Writing context</p><h1>AT THIS POINT IN THE STORY</h1>
<p class="lead-text">Known state through the selected scene, including events in this scene.</p></div></div>
<form asp-action="Index" method="get" class="story-point-selectors" data-story-point-form>
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<div><label for="point-book" class="form-label">Book</label><select id="point-book" class="form-select" data-point-book>
@foreach (var book in Model.Books) { <option value="@book.BookID" selected="@(book.BookID == Model.Snapshot?.Book.BookID)">Book @book.BookNumber: @book.BookDisplayTitle</option> }
</select></div>
<div><label for="point-chapter" class="form-label">Chapter</label><select id="point-chapter" class="form-select" data-point-chapter>
@foreach (var chapter in Model.Chapters) { <option value="@chapter.ChapterID" data-book="@chapter.BookID" selected="@(chapter.ChapterID == Model.Snapshot?.Chapter.ChapterID)">Ch @chapter.ChapterNumber.ToString("g"): @chapter.ChapterTitle</option> }
</select></div>
<div><label for="point-scene" class="form-label">Scene</label><select id="point-scene" class="form-select" name="sceneId" data-point-scene>
@foreach (var scene in Model.Scenes) { <option value="@scene.SceneID" data-chapter="@scene.ChapterID" selected="@(scene.SceneID == Model.Snapshot?.Scene.SceneID)">Scene @scene.SceneNumber.ToString("g"): @scene.SceneTitle</option> }
</select></div>
<button class="btn btn-primary" type="submit">Show state</button>
</form>
<p data-point-feedback role="status" class="text-muted small"></p>
<div data-point-snapshot><partial name="_Snapshot" model="Model.Snapshot" /></div>
@section Scripts { <script src="~/js/story-point.js" asp-append-version="true"></script> }

View File

@ -0,0 +1,85 @@
@model StoryPointSnapshot
@using System.Globalization
@if (Model is null)
{
<p class="text-muted">No scenes are available in this project.</p>
}
else
{
var scene = Model.Scene;
var exactTime = scene.TimeModeName is "Exact DateTime" or "Exact Date and Time";
<article class="story-point" data-scene-id="@scene.SceneID">
<header class="story-point-header">
<p class="eyebrow">@Model.Book.BookDisplayTitle · Chapter @Model.Chapter.ChapterNumber.ToString("g") · Scene @scene.SceneNumber.ToString("g")</p>
<h2>@scene.SceneTitle</h2>
<p class="story-point-date">
@if (scene.StartDateTime.HasValue)
{ <span>@scene.StartDateTime.Value.ToString("dddd, d MMMM yyyy", CultureInfo.GetCultureInfo("en-GB"))</span>
@if (exactTime) { <span> · @scene.StartDateTime.Value.ToString("h:mm tt", CultureInfo.GetCultureInfo("en-GB"))</span> }
}
else { <span>Story date unknown</span> }
@if (!string.IsNullOrWhiteSpace(scene.RelativeTimeText)) { <span> · @scene.RelativeTimeText</span> }
<small class="text-muted"> · @scene.TimeModeName</small>
</p>
<p>POV: <strong>@(scene.POVCharacterName ?? "Not recorded")</strong> · Location: <strong>@(Model.Location?.LocationPath ?? scene.PrimaryLocationName ?? "Not recorded")</strong></p>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-outline-primary" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Open Scene</a>
<a class="btn btn-sm btn-outline-secondary" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.ProjectID" asp-route-bookId="@Model.Book.BookID" asp-route-selectedSceneId="@scene.SceneID">Timeline</a>
@if (scene.StartDateTime.HasValue) { <a class="btn btn-sm btn-outline-secondary" asp-controller="StoryCalendar" asp-action="Index" asp-route-projectId="@Model.ProjectID" asp-route-bookId="@Model.Book.BookID" asp-route-date="@scene.StartDateTime.Value.ToString("yyyy-MM-dd")" asp-route-month="@scene.StartDateTime.Value.ToString("yyyy-MM")">Calendar</a> }
</div>
</header>
<div class="story-point-grid">
<section class="story-point-section story-point-wide"><h3>Characters present <small>@Model.Characters.Count</small></h3>
@if (Model.Characters.Count == 0) { <p class="text-muted">No characters attached to this scene.</p> }
else { <div class="table-responsive"><table class="table table-sm align-middle"><thead><tr><th>Character</th><th>Age at this point</th><th>Known location</th><th>Previous appearance</th></tr></thead><tbody>
@foreach (var person in Model.Characters) { <tr><td><a asp-controller="Characters" asp-action="Edit" asp-route-id="@person.Appearance.CharacterID">@person.Appearance.CharacterName</a>
<small class="d-block text-muted">@person.Appearance.RoleInSceneTypeName @person.Appearance.PresenceTypeName</small></td>
<td>@(person.Age.DisplayText ?? "Unknown") @if (!person.Age.HasExactAge && person.Age.DisplayText is not null) { <small class="d-block text-muted">Approximate</small> }</td>
<td>@(person.State?.CurrentLocation ?? "Unknown")<small class="d-block text-muted">@(person.State?.State == "Inferred" ? "Last known" : "")</small></td>
<td>@if (person.PreviousAppearance is { } previous) { <a asp-controller="Scenes" asp-action="Edit" asp-route-id="@previous.SceneID">@previous.Label</a> } else { <span class="text-muted">First recorded appearance</span> }</td></tr> }
</tbody></table></div> }
</section>
<section class="story-point-section"><h3>Character knowledge</h3>
@if (Model.Characters.All(x => x.Knowledge.Count == 0)) { <p class="text-muted">No structured knowledge recorded by this point.</p> }
@foreach (var person in Model.Characters.Where(x => x.Knowledge.Count > 0)) {
<h4>@person.Appearance.CharacterName</h4><ul class="story-point-facts">
@foreach (var fact in person.Knowledge) { <li><strong>@fact.KnowledgeStateName:</strong> @(string.IsNullOrWhiteSpace(fact.Description) ? fact.AssetName ?? fact.ThreadTitle ?? "Recorded fact" : fact.Description)
<a class="story-point-source" asp-controller="Scenes" asp-action="Edit" asp-route-id="@fact.SceneID">@(Model.Sources.GetValueOrDefault(fact.SceneID)?.Label ?? "Source scene")</a></li> }
</ul>
}
</section>
<section class="story-point-section"><h3>Relationships at this point</h3>
@if (Model.Relationships.Count == 0) { <p class="text-muted">No historically established relationships recorded for these characters.</p> }
<ul class="story-point-facts">@foreach (var relationship in Model.Relationships) {
var source = relationship.History.LastOrDefault(x => x.SceneID.HasValue);
<li><strong>@relationship.CharacterAName ↔ @relationship.CharacterBName</strong><div>@relationship.CurrentStateName</div>
@if (source?.SceneID is int sourceId) { <a class="story-point-source" asp-controller="Scenes" asp-action="Edit" asp-route-id="@sourceId">@(Model.Sources.GetValueOrDefault(sourceId)?.Label ?? "Source scene")</a> }
else { <small class="text-muted">Established at story start</small> }</li>
}</ul></section>
<section class="story-point-section story-point-wide"><h3>Assets / important objects</h3>
@if (Model.Assets.Count == 0) { <p class="text-muted">No relevant assets recorded by this point.</p> }
else { <div class="table-responsive"><table class="table table-sm"><thead><tr><th>Asset</th><th>Holder</th><th>Location</th><th>Status</th><th>Last recorded event</th></tr></thead><tbody>
@foreach (var asset in Model.Assets) { <tr><td><a asp-controller="StoryAssets" asp-action="Edit" asp-route-id="@asset.StoryAssetID">@asset.AssetName</a></td><td>@asset.Holder</td><td>@asset.Location</td><td>@asset.Status</td><td>@asset.LatestEvent
@if (asset.LastConfirmedSceneID is int sourceId) { <a class="story-point-source" asp-controller="Scenes" asp-action="Edit" asp-route-id="@sourceId">@(Model.Sources.GetValueOrDefault(sourceId)?.Label ?? "Source scene")</a> }</td></tr> }
</tbody></table></div> }
</section>
<section class="story-point-section"><h3>Location state</h3>
@if (Model.Location is { } location) { <h4><a asp-controller="Locations" asp-action="Edit" asp-route-id="@location.LocationID">@location.LocationPath</a></h4>
<p><strong>People last known here:</strong> @(location.CharactersPresent.Count == 0 ? "None recorded" : string.Join(", ", location.CharactersPresent))</p>
<p><strong>Assets last known here:</strong> @(location.AssetsPresent.Count == 0 ? "None recorded" : string.Join(", ", location.AssetsPresent))</p>
@if (Model.PreviousLocationUse is { } previous) { <p>Previous scene here: <a asp-controller="Scenes" asp-action="Edit" asp-route-id="@previous.SceneID">@previous.Label</a></p> }
} else { <p class="text-muted">No primary location recorded.</p> }
</section>
<section class="story-point-section"><h3>Active threads / questions / clues</h3>
@if (Model.Threads.Count == 0) { <p class="text-muted">No active threads established by this point.</p> }
<ul class="story-point-facts">@foreach (var thread in Model.Threads) { <li><a asp-controller="PlotThreads" asp-action="Edit" asp-route-id="@thread.PlotThreadID">@thread.Title</a> <small class="text-muted">@thread.Type · @thread.State</small>
@if (thread.Source is { } source) { <a class="story-point-source" asp-controller="Scenes" asp-action="Edit" asp-route-id="@source.SceneID">@source.Label</a> }</li> }</ul>
</section>
<section class="story-point-section story-point-wide"><h3>Things worth checking at this point</h3>
@if (Model.Warnings.Count == 0) { <p class="text-muted">No relevant continuity warnings.</p> }
<ul class="story-point-facts">@foreach (var warning in Model.Warnings) { <li><strong>@warning.Severity:</strong> @warning.Message
@if (warning.SceneID is int sourceId) { <a class="story-point-source" asp-controller="Scenes" asp-action="Edit" asp-route-id="@sourceId">@(Model.Sources.GetValueOrDefault(sourceId)?.Label ?? "Source scene")</a> }</li> }</ul>
</section>
</div>
</article>
}

View File

@ -0,0 +1,16 @@
.story-point-selectors { display: grid; grid-template-columns: 1fr 1fr 1.4fr auto; align-items: end; gap: .75rem; margin-bottom: 1rem; }
.story-point-header { padding: 1.1rem 0; border-bottom: 1px solid var(--plotline-border-soft, var(--bs-border-color)); }
.story-point-header h2 { font-size: 1.45rem; }
.story-point-date { font-size: 1.1rem; }
.story-point-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 2rem; }
.story-point-section { min-width: 0; padding: 1.25rem 0 .65rem; border-bottom: 1px solid var(--plotline-border-soft, var(--bs-border-color)); }
.story-point-section h3 { font-size: 1.05rem; margin-bottom: .8rem; }
.story-point-section h3 small { color: var(--plotline-text-muted, var(--bs-secondary-color)); font-weight: normal; }
.story-point-section h4 { font-size: .95rem; margin: .85rem 0 .4rem; }
.story-point-wide { grid-column: 1 / -1; }
.story-point-facts { padding-left: 1.15rem; margin-bottom: .5rem; }
.story-point-facts li { padding-bottom: .65rem; }
.story-point-source { display: block; font-size: .78rem; margin-top: .15rem; }
.story-point .table { --bs-table-bg: transparent; font-size: .9rem; }
.story-point .table th { color: var(--plotline-text-muted, var(--bs-secondary-color)); font-size: .8rem; font-weight: 600; }
@media (max-width: 800px) { .story-point-selectors { grid-template-columns: 1fr; } .story-point-grid { grid-template-columns: 1fr; } }

View File

@ -0,0 +1,66 @@
(() => {
const form = document.querySelector('[data-story-point-form]');
if (!form) return;
const book = form.querySelector('[data-point-book]');
const chapter = form.querySelector('[data-point-chapter]');
const scene = form.querySelector('[data-point-scene]');
const chapters = [...chapter.options].map(x => x.cloneNode(true));
const scenes = [...scene.options].map(x => x.cloneNode(true));
const snapshot = document.querySelector('[data-point-snapshot]');
const feedback = document.querySelector('[data-point-feedback]');
let pending;
let request = 0;
function filter(select, options, key, value) {
const previous = select.value;
select.replaceChildren(...options.filter(x => x.dataset[key] === value).map(x => x.cloneNode(true)));
if ([...select.options].some(x => x.value === previous)) select.value = previous;
select.disabled = !select.options.length;
}
function filterScenes() { filter(scene, scenes, 'chapter', chapter.value); }
function filterChapters() { filter(chapter, chapters, 'book', book.value); filterScenes(); }
async function load(push = true) {
const current = ++request;
pending?.abort();
if (!scene.value) {
snapshot.replaceChildren();
feedback.textContent = 'No scenes in this selection.';
return;
}
pending = new AbortController();
const url = new URL(form.action, location.href);
url.search = new URLSearchParams(new FormData(form)).toString();
feedback.textContent = 'Loading story state…';
snapshot.replaceChildren();
snapshot.setAttribute('aria-busy', 'true');
try {
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' }, signal: pending.signal });
if (response.redirected) { location.assign(response.url); return; }
if (!response.ok) throw new Error('load');
const html = await response.text();
if (current !== request) return;
snapshot.innerHTML = html;
if (push) history.pushState({}, '', url);
feedback.textContent = 'Story state updated.';
} catch (error) {
if (error.name !== 'AbortError' && current === request) {
snapshot.replaceChildren();
feedback.textContent = 'Story state could not be loaded. Use Show state to retry.';
}
} finally { if (current === request) snapshot.removeAttribute('aria-busy'); }
}
book.addEventListener('change', () => { filterChapters(); load(); });
chapter.addEventListener('change', () => { filterScenes(); load(); });
scene.addEventListener('change', () => load());
form.addEventListener('submit', event => { event.preventDefault(); load(); });
window.addEventListener('popstate', () => {
const id = new URL(location.href).searchParams.get('sceneId');
const chosenScene = scenes.find(x => x.value === id);
const chosenChapter = chapters.find(x => x.value === chosenScene?.dataset.chapter);
if (!chosenScene || !chosenChapter) { location.reload(); return; }
book.value = chosenChapter.dataset.book;
filterChapters(); chapter.value = chosenChapter.value;
filterScenes(); scene.value = id;
load(false);
});
filterChapters();
})();

47
docs/story-point.md Normal file
View File

@ -0,0 +1,47 @@
# At This Point in the Story
## Implementation map (audit before implementation)
| Requirement | Active authority | Approach |
| --- | --- | --- |
| Project, books, chapters, scenes and boundary | TimelineRepository / Timeline_GetByProject; NarrativeSceneOrder view | Reuse project-wide timeline; share Continuity Explorer ordering with SQL's full tie breakers. |
| Scene chronology | Scene canonical StartDateTime, TimeModeName, RelativeTimeText; Calendar consumes these | Display only canonical fields; no parsing or resolution. |
| Characters and previous appearance | CharacterRepository.GetTimelineAsync / SceneCharacters | One batch; selected attachments and strict earlier narrative index, including earlier books. |
| Age | CharacterAgeDisplayService | Reuse directly with selected chronology. |
| Character locations, asset state, location occupancy | StoryStateService (active Scene Inspector and Continuity Explorer path) | Expose reusable bounded projection; retain separate historical asset custody, location and state dimensions. |
| Knowledge | CharacterKnowledge, KnowledgeStates; StoryStateService knowledge identity/regression | Batch read; share identity and select latest state per character/fact through the boundary. No reader/character discriminator exists. |
| Relationships | RelationshipMapService.BuildRelationship | Expose projection using canonical scene indexes; exclude undated non-initial relationships in the new view. |
| Threads, questions and clues | PlotThreads introduction/actual resolution scene IDs; ThreadEvents types | Bounded structured lifecycle projection; no current status or global summary leakage. Questions and clues are existing thread types. |
| Continuity | StoryStateService warning builders and acknowledgement repository | Reuse builders on bounded inputs; focus relevant entities and selected scene. |
| Selection / partial updates | Calendar partial/fetch; Relationship Map hierarchical controls | Purpose-built lightweight dependent selects and reusable snapshot partial, normal GET fallback. |
| Ownership | ProjectAccessFilter / IProjectAccessService | Explicit service access check plus selected scene membership in project timeline. |
| Writing context / Word | StoryContextService loads the full SceneEdit model; WritingController uses it and Word follow partials | Avoid the heavyweight editing path; keep composition service and partial independently reusable. Do not change follow integration. |
No AI, manuscript/synopsis parsing, import changes or chronology writes. New read-only stored procedure batches history that otherwise requires per-character/per-asset/per-scene queries. No table changes.
## Behavior and boundaries
`GET /StoryPoint?projectId=…&sceneId=…` includes the selected scene. The same endpoint returns `_Snapshot` for fetch requests. The composition service checks project access first, then verifies scene membership before loading history. Book/chapter selectors filter locally; changing a scene fetches the snapshot, cancels stale requests, updates the URL and supports browser Back. A normal GET form remains usable without JavaScript.
The existing state service now exposes bounded read projections and independent asset holder/location/status fields. Relationship Map shares the full canonical narrative ordering and exposes its existing relationship projection with explicit start/end/initial-book boundaries. Undated non-initial relationships are withheld because there is no reliable point at which they become true. Current global relationship notes and thread summaries/statuses are not rendered. Knowledge states (including suspicion or unawareness) retain their actual labels rather than all being described as known facts.
Threads use introduction and actual-resolution scene IDs; a first recorded event can establish a thread when the introduction field is absent. `Resolved` and `Payoff` events close a thread, matching `ThreadEvent_Save`. Planned resolution dates do not close it. Questions/clues appear through the existing thread types, not a parallel model.
The service makes eight repository calls (access, timeline, six independent history batches), independent of character/asset/scene count. The six batches run concurrently on separate connections. It does not invoke SceneEdit, AI services, warning persistence, or Story Intelligence. Existing Timeline internals may make an additional batch for event targets; metrics and plot-line loading are disabled here.
## Validation
Six focused behavioral test groups in `PlotLine.Tests/StoryPointTests.cs` cover presence, strict previous appearance across books, age and undated scenes, knowledge progression, relationship boundaries, independent asset state, active/future/resolved threads, warning future-evidence isolation, canonical ordering tie breakers, ownership rejection, partial rendering, and bounded repository calls without AI. They use generic unrelated characters and non-monotonic scene IDs.
Dev SQL: `PlotLine/Sql/191_StoryPointHistory.sql` adds one read-only stored procedure, using `NarrativeSceneOrder` to bound all event reads. No tables, manuscript data or chronology are changed. Applied and smoke-checked against `PlotDirector_Development` (procedure object ID 33435193).
Read-only acceptance on project 4085 (The Alpha Flame), before publication:
- Scene 5244, Chapter 1 Scene 1: 7 May 1980, night; three attached characters; Beth age 15; no previous appearances; one introduced thread; three unknown-location warnings.
- Scene 5400, Chapter 34 Scene 6: 16 April 1983, evening; eleven attached characters; Rosie age 18 years 3 months, Sophie approximate 21; previous appearances correctly point to earlier narrative scenes; six active threads and eleven unknown-location warnings.
- All 299 scene IDs matched the SQL view's narrative ordering exactly. Presence and every previous appearance in both snapshots were independently checked against SQL. Composition took 694 ms on first read and 310 ms on second read.
- The current project contains 31 canonical thread events but zero canonical knowledge, relationship events, asset events, custody events or asset-location rows. Primary locations and character appearance locations are also missing for these two scenes. Empty historical sections are therefore correct; live before/after verification of those empty domains is unavailable. Tests cover their populated behavior without changing dev data.
## Deliberate limits
Reader knowledge is deferred: a few relationship reader-awareness flags do not form a reliable general reader knowledge model. Word Companion automatic following is deferred; the service and partial are reusable for that integration. No AI briefing is included. Unanchored thread/relationship status and asset current-only fields cannot establish historical truth. Entity names and canonical authored facts are not versioned against later author edits; this feature reconstructs narrative event history, not database edit history. Undated chronology and incomplete locations/ages remain explicitly unknown.