From 7dd14f13a9407fccd97e2d374c425ca0e651e363 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Wed, 29 Jul 2026 19:11:52 +0000 Subject: [PATCH] Improve Story Intelligence visualisation assignments --- PlotLine.Tests/Program.cs | 29 +- PlotLine/Controllers/AdminController.cs | 15 ++ PlotLine/Program.cs | 1 + ...toryIntelligenceDevelopmentResetService.cs | 249 ++++++++++++++++++ ...ryIntelligenceIllustrationCompatibility.cs | 8 +- ...IntelligenceIllustrationMatchingService.cs | 16 +- ...ntelligenceVisualisationSnapshotService.cs | 71 +++-- PlotLine/Services/StoryMemoryServices.cs | 12 +- .../Admin/StoryIntelligenceDiagnostics.cshtml | 18 ++ ...tory-intelligence-experience-prototype.css | 53 +++- ...story-intelligence-experience-prototype.js | 72 +++-- 11 files changed, 454 insertions(+), 90 deletions(-) create mode 100644 PlotLine/Services/StoryIntelligenceDevelopmentResetService.cs diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index a746cee..d320bbc 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -1026,7 +1026,7 @@ static void Phase21TRepairsDurableCharacterUnderstanding() var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs")); Assert(service.Contains("BuildCrossSceneIdentityAliases", StringComparison.Ordinal), "Rebuild should repair cross-scene descriptive-to-named character identities from saved analysis."); Assert(service.Contains("ExtensionEvidence(sceneCharacter.ExtensionData)", StringComparison.Ordinal), "Saved scene appearance extension data must be included in durable evidence."); - Assert(snapshot.Contains("NonPovRole(memoryCharacter.Role)", StringComparison.Ordinal), "Carried memory characters must not keep POV role outside the resolved current POV."); + Assert(!snapshot.Contains("memoryCharacter", StringComparison.Ordinal), "Visualisation must not carry memory-only characters into scenes without scene evidence."); Assert(snapshot.Contains("ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model)", StringComparison.Ordinal), "Live and replay must apply durable Story Memory assignments."); } @@ -1094,6 +1094,8 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules() var alisonName = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Alison", ["Alison"], []); var sophie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sophie", ["Sophie glared at me. She called the pub hers."], ["Sophie is Rebecca's sister."]); var gregFromRosieAction = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Greg", ["Greg offers to take Rosie to hospital."], []); + var judyName = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Judy", ["Judy"], []); + var philName = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Phil", ["Phil"], []); var childCandidate = Metadata("char-child", "Child", "Feminine"); var darkRedCandidate = Metadata("char-red-dark", "OlderTeen", "Feminine", hairColour: "Red", skinTone: "Dark"); Assert(bethFromLeakyEvidence.AgeBand != StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Leaked family-role evidence should not age Beth up, got {bethFromLeakyEvidence.AgeBand}."); @@ -1104,6 +1106,8 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules() Assert(alisonName.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Common feminine name Alison should infer Feminine, got {alisonName.Presentation}."); Assert(sophie.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Sophie/she/sister evidence should infer Feminine, got {sophie.Presentation}."); Assert(gregFromRosieAction.Presentation != StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Greg must not inherit feminine presentation from action text mentioning Rosie."); + Assert(judyName.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Common feminine name Judy should infer Feminine, got {judyName.Presentation}."); + Assert(philName.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Common masculine name Phil should infer Masculine, got {philName.Presentation}."); Assert(Rejected(mum, childCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Parent role should reject child portraits."); Assert(Rejected(redHairedMaggie, darkRedCandidate).Any(reason => reason.Contains("skin tone hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-haired light-skin evidence must reject dark-skin generated portraits."); Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}."); @@ -1127,7 +1131,12 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules() var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var onboarding = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligence.cshtml")); + var adminController = File.ReadAllText(Path.Combine(root, "Controllers/AdminController.cs")); + var adminDiagnostics = File.ReadAllText(Path.Combine(root, "Views/Admin/StoryIntelligenceDiagnostics.cshtml")); + var program = File.ReadAllText(Path.Combine(root, "Program.cs")); var storyMemory = File.ReadAllText(Path.Combine(root, "Services/StoryMemoryServices.cs")); + var snapshot = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceVisualisationSnapshotService.cs")); + var developmentReset = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceDevelopmentResetService.cs")); var persistedRunner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs")); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-experience-prototype.js")); var css = File.ReadAllText(Path.Combine(root, "wwwroot/css/story-intelligence-experience-prototype.css")); @@ -1158,6 +1167,20 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules() Assert(storyMemory.Contains("semanticType is \"Road\" or \"Lane\" or \"Street\"", StringComparison.Ordinal), "Road-family locations must be treated as exterior scenes."); Assert(storyMemory.Contains("evidence.HairColour", StringComparison.Ordinal), "Character demand keys/specs must include durable hair colour evidence."); Assert(storyMemory.Contains("Maximum one primary POV", StringComparison.Ordinal) || storyMemory.Contains("ResolveScenePov", StringComparison.Ordinal), "Story Memory must use a single POV resolver."); + Assert(!snapshot.Contains(".Where(character => !sceneCharacterNames.Contains(character.Name))", StringComparison.Ordinal), "Visualisation must not fill scene character slots from memory-only characters."); + Assert(snapshot.Contains("PovResolutionReason = resolved is null ? \"unresolved narrator\" : \"inherited same-chapter POV\"", StringComparison.Ordinal), "Generic narrators should inherit only within the current chapter."); + Assert(!snapshot.Contains("?? bookNarratorName\r\n ?? narratorResolvedName", StringComparison.Ordinal), "Generic narrator resolution must not fall through to stale book-level narrator identity."); + Assert(snapshot.Contains("BuildObservations(scene, resolvedPovName)", StringComparison.Ordinal), "Observation cards should receive the resolved POV identity."); + Assert(snapshot.Contains("NamedPovText", StringComparison.Ordinal), "Observation card text should replace generic narrator wording when POV is known."); + Assert(program.Contains("IStoryIntelligenceDevelopmentResetService", StringComparison.Ordinal), "Admin reset service must be registered."); + Assert(adminController.Contains("ResetStoryIntelligenceImports", StringComparison.Ordinal), "Admin diagnostics must expose the reset post action."); + Assert(adminDiagnostics.Contains("Reset my Story Intelligence data", StringComparison.Ordinal), "Admin diagnostics page must show the reset button."); + Assert(developmentReset.Contains("PreservedIllustrationItems", StringComparison.Ordinal), "Admin reset result must report preserved generated images."); + Assert(!developmentReset.Contains("DELETE FROM dbo.IllustrationLibraryItems", StringComparison.Ordinal), "Admin reset must preserve generated illustration library records."); + Assert(script.Contains("renderPresenceCallout", StringComparison.Ordinal), "Stage should show a presence callout for characters in the active location."); + Assert(script.Contains("drawConnection(id, cluster, locationPoint, \"presence-cluster\", 54, \"\"", StringComparison.Ordinal), "Presence connector should not show a tiny count label."); + Assert(css.Contains(".story-exp-presence-callout", StringComparison.Ordinal), "Presence callout styling is missing."); + Assert(css.Contains("white-space: normal", StringComparison.Ordinal), "Location captions should wrap rather than ellipsise."); Assert(!script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Side panels must not use hidden '+N more' overflow controls."); Assert(css.Contains("-webkit-line-clamp: 3", StringComparison.Ordinal), "Insight body text should be clamped inside its card."); Assert(resetSql.Contains("StoryIntelligence_DevelopmentAccountReset", StringComparison.Ordinal), "Phase 21U reset procedure is missing."); @@ -1189,8 +1212,8 @@ static void Phase21RSceneBrowserAndPanelsAreReplayFriendly() Assert(css.Contains("--story-side-body-font-size: 0.72rem;", StringComparison.Ordinal), "Left and right side panels should share body text sizing."); Assert(!view.Contains("data-assets", StringComparison.Ordinal), "Asset visualisation lane should not be rendered on the main stage."); Assert(script.Contains("Assets are recorded in insights/diagnostics but not rendered as stage nodes.", StringComparison.Ordinal), "Asset data should remain available without drawing asset stage nodes."); - Assert(script.Contains("width * 0.84 - size / 2", StringComparison.Ordinal), "Location should be a compact right-side anchor once asset nodes are removed."); - Assert(script.Contains("{ x: 0.60, y: 0.66, role: \"context\" }", StringComparison.Ordinal), "Character layout should use more of the stage width."); + Assert(script.Contains("width * 0.82 - size / 2", StringComparison.Ordinal), "Location should be a larger right-side anchor once asset nodes are removed."); + Assert(script.Contains("{ x: 0.68, y: 0.26, role: \"context\" }", StringComparison.Ordinal), "Character layout should use more of the stage width."); Assert(script.Contains("presence-cluster", StringComparison.Ordinal), "Presence should be shown as a single location callout instead of many confusing location lines."); Assert(css.Contains(".story-exp-zone--location .story-exp-zone-label {\n left: 80%;", StringComparison.Ordinal), "Location lane label should follow the compact right-side anchor."); Assert(!css.Contains("grid-template-rows: minmax(218px, 1.35fr) minmax(185px, 1.08fr) auto", StringComparison.Ordinal), "Right rail must not use fixed-height panel tracks."); diff --git a/PlotLine/Controllers/AdminController.cs b/PlotLine/Controllers/AdminController.cs index 346133f..c4006a7 100644 --- a/PlotLine/Controllers/AdminController.cs +++ b/PlotLine/Controllers/AdminController.cs @@ -13,6 +13,7 @@ public sealed class AdminController( IFeatureRequestService featureRequests, IEmailService emails, IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics, + IStoryIntelligenceDevelopmentResetService storyIntelligenceDevelopmentReset, IStoryIntelligenceDryRunService storyIntelligenceDryRun, IChapterStructureDryRunService chapterStructureDryRun, IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun, @@ -159,6 +160,20 @@ public sealed class AdminController( return View(await storyIntelligenceDiagnostics.GetDiagnosticsAsync(cancellationToken)); } + [HttpPost("story-intelligence-diagnostics/reset-imports")] + [ValidateAntiForgeryToken] + public async Task ResetStoryIntelligenceImports(CancellationToken cancellationToken) + { + if (currentUser.UserId is not int userId) + { + return Forbid(); + } + + var result = await storyIntelligenceDevelopmentReset.ResetOwnedProjectsAndImportsAsync(userId, cancellationToken); + TempData[result.Success ? "AdminMessage" : "AdminError"] = result.Message; + return RedirectToAction(nameof(StoryIntelligenceDiagnostics)); + } + [HttpGet("story-intelligence-test")] public IActionResult StoryIntelligenceTest() { diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 93b10b8..3c2fbba 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -207,6 +207,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/StoryIntelligenceDevelopmentResetService.cs b/PlotLine/Services/StoryIntelligenceDevelopmentResetService.cs new file mode 100644 index 0000000..583e20b --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceDevelopmentResetService.cs @@ -0,0 +1,249 @@ +using Dapper; +using PlotLine.Data; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceDevelopmentResetService +{ + Task ResetOwnedProjectsAndImportsAsync(int userId, CancellationToken cancellationToken = default); +} + +public sealed class StoryIntelligenceDevelopmentResetService( + ISqlConnectionFactory connectionFactory, + ILogger logger) : IStoryIntelligenceDevelopmentResetService +{ + public async Task ResetOwnedProjectsAndImportsAsync(int userId, CancellationToken cancellationToken = default) + { + using var connection = connectionFactory.CreateConnection(); + var command = new CommandDefinition(PrepareResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120); + var summary = await connection.QuerySingleAsync(command); + + foreach (var projectId in summary.ProjectIdList) + { + var deleteCommand = new CommandDefinition( + "dbo.ProjectHardDelete_ForOwner", + new { ProjectID = projectId, UserID = userId }, + commandType: System.Data.CommandType.StoredProcedure, + cancellationToken: cancellationToken, + commandTimeout: 120); + await connection.ExecuteAsync(deleteCommand); + } + + var verificationCommand = new CommandDefinition(VerifyResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120); + var verification = await connection.QuerySingleAsync(verificationCommand); + var result = new StoryIntelligenceDevelopmentResetResult( + summary.ProjectIdList.Count, + summary.ImportSessionCount, + summary.RunCount, + verification.RemainingProjects, + verification.RemainingImports, + verification.RemainingRuns, + verification.PreservedIllustrationItems); + + logger.LogWarning( + "Admin Story Intelligence reset completed for user {UserId}. DeletedProjects={DeletedProjects} DeletedImports={DeletedImports} DeletedRuns={DeletedRuns} PreservedIllustrationItems={PreservedIllustrationItems}.", + userId, + result.DeletedProjects, + result.DeletedImportSessions, + result.DeletedRuns, + result.PreservedIllustrationItems); + + return result; + } + + private const string PrepareResetSql = """ +SET NOCOUNT ON; + +DECLARE @ProjectIDs TABLE (ProjectID int NOT NULL PRIMARY KEY); +DECLARE @BookIDs TABLE (BookID int NOT NULL PRIMARY KEY); +DECLARE @ImportSessionIDs TABLE (ImportSessionID int NOT NULL PRIMARY KEY); +DECLARE @RunIDs TABLE (StoryIntelligenceRunID int NOT NULL PRIMARY KEY); + +INSERT @ProjectIDs (ProjectID) +SELECT DISTINCT p.ProjectID +FROM dbo.Projects p +INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID +WHERE pua.UserID = @UserID + AND pua.AccessRole = N'Owner' + AND pua.IsActive = 1; + +INSERT @BookIDs (BookID) +SELECT b.BookID +FROM dbo.Books b +INNER JOIN @ProjectIDs p ON p.ProjectID = b.ProjectID; + +INSERT @ImportSessionIDs (ImportSessionID) +SELECT StoryIntelligenceBookPipelineID +FROM dbo.StoryIntelligenceBookPipelines pipeline +WHERE EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = pipeline.ProjectID) + OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = pipeline.BookID); + +INSERT @RunIDs (StoryIntelligenceRunID) +SELECT StoryIntelligenceRunID +FROM dbo.StoryIntelligenceRuns run +WHERE run.UserID = @UserID + AND ( + run.ProjectID IS NULL + OR EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = run.ProjectID) + OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = run.BookID) + ); + +DECLARE @ImportSessionCount int = (SELECT COUNT(*) FROM @ImportSessionIDs); +DECLARE @RunCount int = (SELECT COUNT(*) FROM @RunIDs); + +UPDATE dbo.StoryIntelligenceRuns +SET Status = CASE WHEN Status IN (N'Pending', N'Running') THEN N'Cancelled' ELSE Status END, + CurrentStage = N'Cancelled before admin reset', + CurrentMessage = N'Admin reset invalidated this run while preserving generated illustrations.', + CancellationRequestedUtc = COALESCE(CancellationRequestedUtc, SYSUTCDATETIME()), + CancelledUtc = COALESCE(CancelledUtc, SYSUTCDATETIME()), + CompletedUtc = COALESCE(CompletedUtc, SYSUTCDATETIME()), + UpdatedUtc = SYSUTCDATETIME() +WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceRuns.StoryIntelligenceRunID) + AND Status IN (N'Pending', N'Running'); + +DELETE FROM dbo.StoryMemoryIllustrationAssignments +WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryIllustrationAssignments.ImportSessionID); + +DELETE FROM dbo.StoryMemoryIllustrationDemands +WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryIllustrationDemands.ImportSessionID); + +DELETE FROM dbo.StoryMemoryRelationships +WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryRelationships.ImportSessionID); + +DELETE FROM dbo.StoryMemoryCharacterAttributes +WHERE EXISTS +( + SELECT 1 + FROM dbo.StoryMemoryCharacters c + INNER JOIN @ImportSessionIDs ids ON ids.ImportSessionID = c.ImportSessionID + WHERE c.StoryMemoryCharacterID = StoryMemoryCharacterAttributes.StoryMemoryCharacterID +); + +DELETE FROM dbo.StoryMemoryCharacterAliases +WHERE EXISTS +( + SELECT 1 + FROM dbo.StoryMemoryCharacters c + INNER JOIN @ImportSessionIDs ids ON ids.ImportSessionID = c.ImportSessionID + WHERE c.StoryMemoryCharacterID = StoryMemoryCharacterAliases.StoryMemoryCharacterID +); + +DELETE FROM dbo.StoryMemoryAssets +WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryAssets.ImportSessionID); + +DELETE FROM dbo.StoryMemoryLocations +WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryLocations.ImportSessionID); + +DELETE FROM dbo.StoryMemoryCharacters +WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryCharacters.ImportSessionID); + +DELETE FROM dbo.StoryMemoryProcessedSceneResults +WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryProcessedSceneResults.ImportSessionID); + +DELETE FROM dbo.StoryMemoryImportPreferences +WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryImportPreferences.ImportSessionID); + +DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments +WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceCharacterIllustrationAssignments.ImportSessionID) + OR EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceCharacterIllustrationAssignments.ProjectID); + +DELETE FROM dbo.StoryIntelligenceIllustrationDemands +WHERE EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceIllustrationDemands.ProjectID); + +DELETE FROM dbo.StoryIntelligenceBookPipelines +WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceBookPipelines.StoryIntelligenceBookPipelineID); + +DELETE FROM dbo.StoryIntelligenceSceneResults +WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceSceneResults.StoryIntelligenceRunID); + +DELETE FROM dbo.StoryIntelligenceChapterResults +WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceChapterResults.StoryIntelligenceRunID); + +DELETE FROM dbo.StoryIntelligenceImportCommits +WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceImportCommits.StoryIntelligenceRunID); + +DELETE FROM dbo.StoryIntelligenceRuns +WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceRuns.StoryIntelligenceRunID); + +UPDATE dbo.Projects +SET IsArchived = 1, + ArchivedDate = COALESCE(ArchivedDate, SYSUTCDATETIME()), + ArchivedReason = N'Admin Story Intelligence reset preserving generated illustrations', + UpdatedDate = SYSUTCDATETIME() +WHERE EXISTS (SELECT 1 FROM @ProjectIDs ids WHERE ids.ProjectID = Projects.ProjectID); + +SELECT + ProjectIds = COALESCE(STRING_AGG(CONVERT(varchar(20), ProjectID), ','), ''), + ImportSessionCount = @ImportSessionCount, + RunCount = @RunCount +FROM @ProjectIDs; +"""; + + private const string VerifyResetSql = """ +SELECT + RemainingProjects = ( + SELECT COUNT(*) + FROM dbo.Projects p + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE pua.UserID = @UserID AND pua.AccessRole = N'Owner' AND pua.IsActive = 1 + ), + RemainingImports = ( + SELECT COUNT(*) + FROM dbo.StoryIntelligenceBookPipelines pipeline + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = pipeline.ProjectID + WHERE pua.UserID = @UserID AND pua.AccessRole = N'Owner' AND pua.IsActive = 1 + ), + RemainingRuns = ( + SELECT COUNT(*) + FROM dbo.StoryIntelligenceRuns + WHERE UserID = @UserID + ), + PreservedIllustrationItems = ( + SELECT COUNT(*) + FROM dbo.IllustrationLibraryItems + WHERE Category IN (N'Character', N'Location', N'Asset') + ); +"""; + + private sealed class PreparedResetSummary + { + public string ProjectIds { get; init; } = string.Empty; + public int ImportSessionCount { get; init; } + public int RunCount { get; init; } + + public IReadOnlyList ProjectIdList => SplitProjectIds(ProjectIds); + } + + private sealed class ResetVerification + { + public int RemainingProjects { get; init; } + public int RemainingImports { get; init; } + public int RemainingRuns { get; init; } + public int PreservedIllustrationItems { get; init; } + } + + private static IReadOnlyList SplitProjectIds(string projectIds) + => string.IsNullOrWhiteSpace(projectIds) + ? [] + : projectIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(value => int.TryParse(value, out var id) ? id : 0) + .Where(id => id > 0) + .ToArray(); +} + +public sealed record StoryIntelligenceDevelopmentResetResult( + int DeletedProjects, + int DeletedImportSessions, + int DeletedRuns, + int RemainingProjects, + int RemainingImportSessions, + int RemainingRuns, + int PreservedIllustrationItems) +{ + public bool Success => RemainingProjects == 0 && RemainingImportSessions == 0 && RemainingRuns == 0; + + public string Message => Success + ? $"Reset complete. Deleted {DeletedProjects:N0} project(s), {DeletedImportSessions:N0} import(s) and {DeletedRuns:N0} run(s). Preserved {PreservedIllustrationItems:N0} generated image record(s)." + : $"Reset finished with remaining data: {RemainingProjects:N0} project(s), {RemainingImportSessions:N0} import(s), {RemainingRuns:N0} run(s). Preserved {PreservedIllustrationItems:N0} generated image record(s)."; +} diff --git a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs index bd1a2ea..b0ae425 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs @@ -522,8 +522,8 @@ public static class StoryIntelligenceIllustrationCompatibility private static EvidenceSignal NamePresentation(string text) { - if (ContainsAny(text, " alison ", " sophie ", " beth ", " maggie ", " rosie ", " grace ", " zoe ", " rebecca ", " annie ", " mary ", " sharon ", " catherine ", " susan ", " helen ", " elen ")) return new(Presentations.Feminine, 0.82m); - if (ContainsAny(text, " adam ", " colin ", " david ", " kevin ", " simon ", " graham ", " gareth ", " john ", " michael ", " peter ", " richard ", " rick ", " george ")) return new(Presentations.Masculine, 0.82m); + if (ContainsAny(text, " alison ", " annie ", " anna ", " anne ", " beth ", " catherine ", " chrissy ", " grace ", " helen ", " elen ", " judy ", " katherine ", " maggie ", " mary ", " rebecca ", " rosie ", " sharon ", " sophie ", " susan ", " zoe ")) return new(Presentations.Feminine, 0.82m); + if (ContainsAny(text, " adam ", " colin ", " david ", " gareth ", " george ", " graham ", " greg ", " john ", " kevin ", " michael ", " peter ", " phil ", " richard ", " rick ", " rob ", " roger ", " simon ")) return new(Presentations.Masculine, 0.82m); return EvidenceSignal.Unknown; } @@ -559,13 +559,13 @@ public static class StoryIntelligenceIllustrationCompatibility } if (StartsWithAny(clean, "mr", "father", "dad", "uncle", "brother", "son", "boy", "man", "male") - || clean is "adam" or "colin" or "david" or "kevin" or "simon" or "graham" or "gareth" or "john" or "michael" or "peter" or "george" or "richard" or "rick") + || clean is "adam" or "colin" or "david" or "gareth" or "george" or "graham" or "greg" or "john" or "kevin" or "michael" or "peter" or "phil" or "richard" or "rick" or "rob" or "roger" or "simon") { return new(Presentations.Masculine, 0.98m); } if (StartsWithAny(clean, "mrs", "miss", "ms", "mother", "mum", "mom", "aunt", "sister", "daughter", "girl", "woman", "female") - || clean is "alison" or "sophie" or "beth" or "maggie" or "grace" or "rosie" or "rebecca" or "annie" or "mary" or "susan" or "helen" or "elen" or "zoe" or "sharon" or "catherine") + || clean is "alison" or "annie" or "anna" or "anne" or "beth" or "catherine" or "chrissy" or "grace" or "helen" or "elen" or "judy" or "katherine" or "maggie" or "mary" or "rebecca" or "rosie" or "sharon" or "sophie" or "susan" or "zoe") { return new(Presentations.Feminine, 0.98m); } diff --git a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs index 2574b86..4879261 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs @@ -591,8 +591,8 @@ public sealed class StoryIntelligenceIllustrationMatchingService( Code = $"char-demand-{StableHash(demand.DemandKey)}", Title = $"{age} {presentation.ToLowerInvariant()} character", Description = "Demand-created reusable character illustration for Story Intelligence matching.", - VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair. Skin tone requirement: {SkinToneInstruction(skinTone)}.", - Composition = "clear head-and-shoulders cinematic portrait with readable face, subtle atmospheric story background, clean separation, suitable for circular cropping", + VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair. Skin tone requirement: {SkinToneInstruction(skinTone)}. The face must read immediately as {PresentationPrompt(presentation)} with distinct facial structure, expression, jaw, eyes, nose and hairstyle silhouette.", + Composition = "clear head-and-shoulders cinematic portrait with readable face, subtle atmospheric story background, clean separation, suitable for circular cropping, visibly different from other reusable portraits", Mood = "neutral, story-ready, emotionally readable, subdued and cinematic", ApparentAgeBand = age, Presentation = presentation, @@ -697,6 +697,14 @@ public sealed class StoryIntelligenceIllustrationMatchingService( _ => "not specified" }; + private static string PresentationPrompt(string presentation) + => presentation switch + { + StoryIntelligenceIllustrationCompatibility.Presentations.Feminine => "a recognisably feminine woman or girl, not androgynous and not masculine", + StoryIntelligenceIllustrationCompatibility.Presentations.Masculine => "a recognisably masculine man or boy, not androgynous and not feminine", + _ => "the requested presentation" + }; + private static Dictionary BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype) { var observations = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -781,12 +789,12 @@ public sealed class StoryIntelligenceIllustrationMatchingService( private static EvidenceSignal NamePresentation(string text) { - if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " annie ", " helen ", " elen ")) + if (ContainsAny(text, " alison ", " annie ", " anna ", " anne ", " beth ", " chrissy ", " grace ", " helen ", " elen ", " judy ", " katherine ", " maggie ", " mary ", " rebecca ", " rosie ", " sharon ", " sophie ")) { return new("Feminine", 0.82m); } - if (ContainsAny(text, " graham ", " george ", " john ", " david ", " simon ", " kevin ", " colin ")) + if (ContainsAny(text, " colin ", " david ", " george ", " graham ", " greg ", " john ", " kevin ", " phil ", " roger ", " rick ", " rob ", " simon ")) { return new("Masculine", 0.82m); } diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index 4c6ec1c..10b219a 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -349,6 +349,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( } var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID; + var resolvedPovName = storyMemory.ResolvePointOfView(scene); yield return new StoryIntelligenceExperienceSceneState { Id = $"scene-result-{item.Result.SceneResultID}", @@ -373,13 +374,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( SceneTotal = Math.Max(1, overallTotalScenes ?? totalScenes), ElapsedTime = overallElapsed ?? EstimateElapsed(run), EstimatedRemaining = overallRemaining ?? EstimateRemaining(run, completedScenes, totalScenes), - PovCharacterId = CharacterId(storyMemory.ResolvePointOfView(scene)), + PovCharacterId = CharacterId(resolvedPovName), Location = BuildLocation(scene), Characters = BuildCharacters(scene, storyMemory), Assets = BuildAssets(scene), Relationships = BuildRelationships(scene, storyMemory), KnowledgeThreads = BuildKnowledge(scene), - Observations = BuildObservations(scene), + Observations = BuildObservations(scene, resolvedPovName), LatestDiscoveries = isCurrent ? BuildDiscoveries(scene).ToList() : [], ActivityFeed = isCurrent ? BuildActivity(run, sceneNumber).ToList() : [] }; @@ -478,7 +479,6 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( var povName = Clean(narratorResolution ?? scene.PointOfView?.CharacterName); var states = new List(); var reservedCodes = new HashSet(StringComparer.OrdinalIgnoreCase); - var sceneCharacterNames = new HashSet((scene.Characters ?? []).Select(character => Clean(character.Name)), StringComparer.OrdinalIgnoreCase); foreach (var group in (scene.Characters ?? []) .Where(character => !string.IsNullOrWhiteSpace(character.Name)) .GroupBy(character => Clean(character.Name), StringComparer.OrdinalIgnoreCase) @@ -520,30 +520,6 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( }); } - if (storyMemory is not null) - { - foreach (var memoryCharacter in storyMemory.Characters - .Where(character => !sceneCharacterNames.Contains(character.Name)) - .OrderByDescending(character => character.Weight) - .Take(Math.Max(0, 5 - states.Count))) - { - var libraryCode = CharacterLibraryCode(memoryCharacter.Name, null, scene, reservedCodes); - reservedCodes.Add(libraryCode); - states.Add(new StoryIntelligenceExperienceCharacterState - { - Id = CharacterId(memoryCharacter.Name), - LibraryCode = libraryCode, - Name = memoryCharacter.Name, - Role = string.Equals(CharacterId(memoryCharacter.Name), CharacterId(povName), StringComparison.OrdinalIgnoreCase) - ? "POV character" - : NonPovRole(memoryCharacter.Role), - Relevance = memoryCharacter.Relevance, - Weight = Math.Max(38, memoryCharacter.Weight - 18), - ImagePath = CharacterFallback(memoryCharacter.Name) - }); - } - } - return states .OrderByDescending(character => character.Weight) .GroupBy(character => character.Id, StringComparer.OrdinalIgnoreCase) @@ -778,17 +754,36 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( return questions.Concat(knowledge).Take(4).ToList(); } - private static IReadOnlyList BuildObservations(SceneIntelligenceScene scene) + private static IReadOnlyList BuildObservations(SceneIntelligenceScene scene, string? povName) => (scene.Observations ?? []).Select(observation => new StoryIntelligenceExperienceTextItem { Id = $"observation-result-{StableKey(observation.ObservationType)}-{StableKey(observation.SubjectName)}-{StableKey(observation.Predicate)}", - Title = Trim(FirstConfigured(observation.Description, observation.Predicate, observation.ObservationType, "Story observation"), 74), - Detail = Trim(FirstConfigured(observation.Evidence, $"{observation.SubjectName} {observation.Predicate} {observation.ObjectName}".Trim(), "Observed in current scene"), 128), + Title = Trim(NamedPovText(FirstConfigured(observation.Description, observation.Predicate, observation.ObservationType, "Story observation"), povName), 74), + Detail = Trim(NamedPovText(FirstConfigured(observation.Evidence, $"{observation.SubjectName} {observation.Predicate} {observation.ObjectName}".Trim(), "Observed in current scene"), povName), 128), Tone = "insight" }) .Take(4) .ToList(); + private static string NamedPovText(string text, string? povName) + { + var name = Clean(povName); + if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(name) || IsGenericNarratorName(name)) + { + return text; + } + + return text + .Replace("The narrator's", $"{name}'s", StringComparison.OrdinalIgnoreCase) + .Replace("the narrator's", $"{name}'s", StringComparison.OrdinalIgnoreCase) + .Replace("The narrator", name, StringComparison.OrdinalIgnoreCase) + .Replace("the narrator", name, StringComparison.OrdinalIgnoreCase) + .Replace("Narrator's", $"{name}'s", StringComparison.OrdinalIgnoreCase) + .Replace("narrator's", $"{name}'s", StringComparison.OrdinalIgnoreCase) + .Replace("Narrator", name, StringComparison.OrdinalIgnoreCase) + .Replace("narrator", name, StringComparison.OrdinalIgnoreCase); + } + private static IEnumerable BuildDiscoveries(SceneIntelligenceScene scene) { foreach (var character in (scene.Characters ?? []).Where(item => !string.IsNullOrWhiteSpace(item.Name)).Take(2)) @@ -1125,19 +1120,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( if (IsGenericNarratorName(raw)) { var resolved = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name - ?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null) - ?? bookNarratorName - ?? narratorResolvedName; + ?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null); ResolvedPovIdentity = resolved ?? raw; - PovResolutionReason = resolved is null ? "unresolved narrator" : "inherited resolved chapter POV"; + PovResolutionReason = resolved is null ? "unresolved narrator" : "inherited same-chapter POV"; PovConfidence = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Confidence ?? (resolved is null ? 0.35m : 0.74m); return resolved ?? raw; } var fallback = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name - ?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null) - ?? bookNarratorName - ?? narratorResolvedName; + ?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null); ResolvedPovIdentity = string.IsNullOrWhiteSpace(raw) ? fallback ?? string.Empty : raw; PovResolutionReason = string.IsNullOrWhiteSpace(raw) ? "inherited previous POV" : "explicit named POV"; PovConfidence = string.IsNullOrWhiteSpace(raw) ? 0.72m : 0.92m; @@ -1166,8 +1157,6 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( return chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name ?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null) - ?? bookNarratorName - ?? narratorResolvedName ?? name; } @@ -1373,12 +1362,12 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( private static string? NamePresentation(string name) { var key = name.ToLowerInvariant(); - if (key is "beth" or "rosie" or "maggie" or "grace" or "annie" or "anna" or "anne" or "helen" or "elen") + if (key is "alison" or "annie" or "anna" or "anne" or "beth" or "chrissy" or "grace" or "helen" or "elen" or "judy" or "katherine" or "maggie" or "mary" or "rebecca" or "rosie" or "sharon" or "sophie") { return "Feminine"; } - if (key is "graham" or "simon" or "kevin" or "colin" or "john" or "david") + if (key is "colin" or "david" or "george" or "graham" or "greg" or "john" or "kevin" or "phil" or "roger" or "rick" or "rob" or "simon") { return "Masculine"; } diff --git a/PlotLine/Services/StoryMemoryServices.cs b/PlotLine/Services/StoryMemoryServices.cs index 8c7c705..1c2f080 100644 --- a/PlotLine/Services/StoryMemoryServices.cs +++ b/PlotLine/Services/StoryMemoryServices.cs @@ -996,8 +996,8 @@ public sealed class StoryMemoryService( Code = code, Title = $"{age} {presentation} character", Description = "Reusable character portrait generated from durable Story Memory appearance evidence. Not canonical to a specific manuscript.", - VisualSubject = $"A {agePrompt} {presentation.ToLowerInvariant()} person from {era} {location} with unmistakable {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair. Skin tone requirement: {skinInstruction}. Give this person a distinct face shape, nose, eyes, jaw, expression, and hairstyle silhouette so they do not resemble other reusable portraits.", - Composition = "single head-and-shoulders portrait with clear facial lighting, neutral story background, strong thumbnail readability, and visibly individual facial features", + VisualSubject = $"A {agePrompt} {presentation.ToLowerInvariant()} person from {era} {location} with unmistakable {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair. Skin tone requirement: {skinInstruction}. The face must read immediately as {PresentationPrompt(presentation)}. Give this person a distinct face shape, nose, eyes, jaw, expression, and hairstyle silhouette so they do not resemble other reusable portraits.", + Composition = "single head-and-shoulders portrait with clear facial lighting, neutral story background, strong thumbnail readability, visibly individual facial features, clear gender presentation when specified", Mood = "present, natural, story-aware", ApparentAgeBand = age, Presentation = presentation, @@ -1123,6 +1123,14 @@ public sealed class StoryMemoryService( _ => "not specified" }; + private static string PresentationPrompt(string presentation) + => presentation switch + { + StoryIntelligenceIllustrationCompatibility.Presentations.Feminine => "a recognisably feminine woman or girl, not androgynous and not masculine", + StoryIntelligenceIllustrationCompatibility.Presentations.Masculine => "a recognisably masculine man or boy, not androgynous and not feminine", + _ => "the requested presentation" + }; + private static string SplitWords(string? value) { var clean = UnknownToDefault(value, "Unknown"); diff --git a/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml b/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml index a666ebe..32c87af 100644 --- a/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml +++ b/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml @@ -23,6 +23,16 @@
@Model.ErrorMessage
} +@if (TempData["AdminMessage"] is string message) +{ +
@message
+} + +@if (TempData["AdminError"] is string error) +{ +
@error
+} +

OpenAI configuration

@@ -59,6 +69,14 @@
+
+

Development reset

+

Delete your projects, books, Story Intelligence imports, runs and story memory. Generated image library records and files are preserved.

+
+ +
+
+

Prompt repository

diff --git a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css index 00fd0a1..de7992c 100644 --- a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css +++ b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css @@ -792,6 +792,37 @@ input:focus-visible { height: 100%; } +.story-exp-presence-callout { + position: absolute; + left: var(--presence-left, 28px); + top: var(--presence-top, 58px); + z-index: 78; + width: var(--presence-width, 520px); + height: var(--presence-height, 320px); + border: 1px solid rgba(125, 193, 255, 0.2); + border-radius: 36px; + background: + radial-gradient(circle at 65% 45%, rgba(87, 149, 205, 0.18), transparent 52%), + linear-gradient(135deg, rgba(17, 41, 70, 0.22), rgba(4, 10, 20, 0.06)); + box-shadow: inset 0 0 44px rgba(125, 193, 255, 0.08), 0 22px 80px rgba(0, 0, 0, 0.16); + pointer-events: none; + transition: left 850ms cubic-bezier(.16, .84, .28, 1), top 850ms cubic-bezier(.16, .84, .28, 1), width 850ms cubic-bezier(.16, .84, .28, 1), height 850ms cubic-bezier(.16, .84, .28, 1); +} + +.story-exp-presence-callout::after { + position: absolute; + right: -34px; + top: 48%; + width: 70px; + height: 44px; + border-top: 1px solid rgba(125, 193, 255, 0.2); + border-right: 1px solid rgba(125, 193, 255, 0.2); + border-radius: 0 28px 0 0; + background: linear-gradient(90deg, rgba(87, 149, 205, 0.14), transparent); + content: ""; + transform: skewX(-18deg); +} + .story-character { position: absolute; display: block; @@ -966,8 +997,8 @@ input:focus-visible { position: absolute; display: grid; justify-items: center; - gap: 10px; - width: var(--node-size, 190px); + gap: 12px; + width: min(292px, calc(var(--node-size, 190px) + 52px)); transform: translate(var(--x, 0), var(--y, 0)); z-index: 42; pointer-events: auto; @@ -998,7 +1029,8 @@ input:focus-visible { display: grid; justify-items: center; gap: 3px; - max-width: min(244px, calc(100vw - 32px)); + width: min(292px, calc(var(--node-size, 190px) + 54px)); + max-width: min(292px, calc(100vw - 32px)); border: 1px solid rgba(125, 193, 255, 0.12); border-radius: 12px; padding: 7px 10px; @@ -1008,23 +1040,24 @@ input:focus-visible { } .story-location h2 { - overflow: hidden; + overflow: visible; max-width: 100%; - font-size: clamp(1.12rem, 1.55vw, 1.55rem); + font-size: clamp(1.02rem, 1.18vw, 1.24rem); + line-height: 1.06; text-align: center; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; + white-space: normal; } .story-location p { - overflow: hidden; + overflow: visible; max-width: 100%; color: var(--story-muted); font-size: 0.78rem; line-height: 1.15; text-align: center; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; + white-space: normal; } .story-exp-ribbon-wrap { diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index 82d399c..befd4e2 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -682,38 +682,56 @@ const activeIds = new Set(); const occupiedRects = layoutRects(stageRect); - scene.relationships - .filter((relationship) => relationship.weight >= 72) - .slice(0, 2) - .forEach((relationship, index) => { - const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`); - const target = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.targetId)}"]`); - if (!source || !target) return; - - const start = connectionPoint(source, stageRect, target); - const end = connectionPoint(target, stageRect, source); - const obstacles = connectionObstacles(stageRect, source, target); - const id = relationship.id || `relationship-${relationship.sourceId}-${relationship.targetId}`; - activeIds.add(id); - drawn.push(drawConnection(id, start, end, relationshipTone(relationship), relationship.weight, "", index, obstacles, occupiedRects)); - }); - const location = dom.visualStage.querySelector("[data-location-id]"); const presentCharacters = scene.characters .filter((character) => !character.status || !character.status.toLowerCase().includes("absent")) .map((character) => dom.visualStage.querySelector(`[data-character-id="${cssEscape(character.id)}"]`)) .filter(Boolean); const cluster = characterClusterPoint(presentCharacters, stageRect); + renderPresenceCallout(presentCharacters, location, stageRect); if (cluster && location) { const id = `presence-cluster-${location.dataset.locationId}`; activeIds.add(id); const locationPoint = connectionPoint(location, stageRect, { getBoundingClientRect: () => ({ left: stageRect.left + cluster.x, top: stageRect.top + cluster.y, width: 0, height: 0 }) }); - drawn.push(drawConnection(id, cluster, locationPoint, "presence-cluster", 54, `${presentCharacters.length} here`, drawn.length, [], occupiedRects)); + drawn.push(drawConnection(id, cluster, locationPoint, "presence-cluster", 54, "", drawn.length, [], occupiedRects)); } removeInactiveSvgConnections(activeIds); } + function renderPresenceCallout(characterNodes, locationNode, stageRect) { + let callout = dom.visualStage.querySelector("[data-presence-callout]"); + if (!characterNodes.length || !locationNode) { + callout?.remove(); + return; + } + + if (!callout) { + callout = document.createElement("div"); + callout.className = "story-exp-presence-callout"; + callout.dataset.presenceCallout = "true"; + dom.visualStage.append(callout); + } + + const rects = characterNodes + .map((node) => node.getBoundingClientRect()) + .filter((rect) => rect.width > 0 && rect.height > 0); + if (!rects.length) { + callout.remove(); + return; + } + + const left = Math.min(...rects.map((rect) => rect.left)) - stageRect.left; + const top = Math.min(...rects.map((rect) => rect.top)) - stageRect.top; + const right = Math.max(...rects.map((rect) => rect.right)) - stageRect.left; + const bottom = Math.max(...rects.map((rect) => rect.bottom)) - stageRect.top; + const padding = Math.max(28, Math.min(54, stageRect.width * 0.035)); + callout.style.setProperty("--presence-left", `${Math.max(12, left - padding)}px`); + callout.style.setProperty("--presence-top", `${Math.max(46, top - padding)}px`); + callout.style.setProperty("--presence-width", `${Math.min(stageRect.width * 0.72, right - left + padding * 2)}px`); + callout.style.setProperty("--presence-height", `${Math.min(stageRect.height * 0.82, bottom - top + padding * 2 + 62)}px`); + } + function drawConnection(id, start, end, tone, weight, label, index, obstacles, occupiedRects) { let group = dom.connections.querySelector(`[data-connection-id="${cssEscape(id)}"]`); const isNew = !group; @@ -807,7 +825,7 @@ const povSize = Math.round(Math.min(188, Math.max(150, width * 0.142))); if (pov) { positions.set(pov.id, { - x: clamp(Math.round(width * 0.28 - povSize / 2), 28, width * 0.58 - povSize), + x: clamp(Math.round(width * 0.28 - povSize / 2), 28, width * 0.66 - povSize), y: clamp(Math.round(height * 0.28 - povSize / 2), 54, height * 0.54 - povSize), size: povSize, scale: 1, @@ -819,14 +837,16 @@ } const slots = [ - { x: 0.55, y: 0.46, role: "partner" }, + { x: 0.57, y: 0.46, role: "partner" }, { x: 0.14, y: 0.58, role: "support" }, { x: 0.12, y: 0.18, role: "support" }, - { x: 0.42, y: 0.72, role: "context" }, - { x: 0.34, y: 0.46, role: "context" }, - { x: 0.60, y: 0.66, role: "context" }, + { x: 0.47, y: 0.72, role: "context" }, + { x: 0.38, y: 0.47, role: "context" }, + { x: 0.64, y: 0.66, role: "context" }, { x: 0.06, y: 0.38, role: "context" }, - { x: 0.24, y: 0.76, role: "context" } + { x: 0.28, y: 0.76, role: "context" }, + { x: 0.68, y: 0.26, role: "context" }, + { x: 0.50, y: 0.20, role: "context" } ]; const placed = [...positions.values()]; others.forEach((character, index) => { @@ -904,9 +924,9 @@ const bounds = dom.visualStage?.getBoundingClientRect(); const width = Math.max(820, bounds?.width || 1040); const height = Math.max(500, bounds?.height || 560); - const size = Math.min(188, Math.max(154, width * 0.14)); + const size = Math.min(246, Math.max(200, width * 0.185)); return { - x: Math.round(width * 0.84 - size / 2), + x: Math.round(width * 0.82 - size / 2), y: Math.round(height * 0.46 - size / 2), size }; @@ -1136,7 +1156,7 @@ } function conciseLocationLabel(location) { - return compactPhrase(location.label, 30); + return location.label || ""; } function conciseAssetLabel(asset) {