Phase 12C: Inferred Location Activity for Continuity Explorer.
This commit is contained in:
parent
dc4324f970
commit
c7e02870cf
@ -152,6 +152,7 @@ public interface IAssetRepository
|
||||
Task DeleteDependencyAsync(int assetDependencyId);
|
||||
Task<IReadOnlyList<AssetCustodyEvent>> ListCustodyBySceneAsync(int sceneId);
|
||||
Task<IReadOnlyList<AssetCustodyEvent>> ListCustodyByAssetAsync(int storyAssetId);
|
||||
Task<IReadOnlyList<AssetCustodyCharacter>> ListCustodyCharactersByProjectAsync(int projectId);
|
||||
Task<int> SaveCustodyEventAsync(AssetCustodyEvent custodyEvent, string custodianNames, IEnumerable<int> custodianCharacterIds, int custodyRoleId);
|
||||
Task DeleteCustodyEventAsync(int assetCustodyEventId);
|
||||
Task<(IReadOnlyList<StoryAsset> Assets, IReadOnlyList<AssetEvent> Events)> GetTimelineAsync(int projectId, int? bookId);
|
||||
@ -2775,6 +2776,31 @@ public sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : I
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AssetCustodyCharacter>> ListCustodyCharactersByProjectAsync(int projectId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<AssetCustodyCharacter>(
|
||||
"""
|
||||
SELECT acec.AssetCustodyEventID,
|
||||
ace.StoryAssetID,
|
||||
ace.SceneID,
|
||||
acec.CharacterID,
|
||||
c.CharacterName,
|
||||
acec.CharacterNameText,
|
||||
cr.RoleName,
|
||||
ace.UpdatedDate
|
||||
FROM dbo.AssetCustodyEventCharacters acec
|
||||
INNER JOIN dbo.AssetCustodyEvents ace ON ace.AssetCustodyEventID = acec.AssetCustodyEventID
|
||||
INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ace.StoryAssetID
|
||||
LEFT JOIN dbo.Characters c ON c.CharacterID = acec.CharacterID
|
||||
LEFT JOIN dbo.CustodyRoles cr ON cr.CustodyRoleID = acec.CustodyRoleID
|
||||
WHERE sa.ProjectID = @ProjectID
|
||||
AND sa.IsArchived = 0;
|
||||
""",
|
||||
new { ProjectID = projectId });
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<int> SaveCustodyEventAsync(AssetCustodyEvent custodyEvent, string custodianNames, IEnumerable<int> custodianCharacterIds, int custodyRoleId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -688,6 +688,18 @@ public sealed class AssetCustodyEvent
|
||||
public DateTime UpdatedDate { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AssetCustodyCharacter
|
||||
{
|
||||
public int AssetCustodyEventID { get; set; }
|
||||
public int StoryAssetID { get; set; }
|
||||
public int SceneID { get; set; }
|
||||
public int? CharacterID { get; set; }
|
||||
public string? CharacterName { get; set; }
|
||||
public string? CharacterNameText { get; set; }
|
||||
public string RoleName { get; set; } = string.Empty;
|
||||
public DateTime UpdatedDate { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AssetDependencyIssue
|
||||
{
|
||||
public int AssetDependencyID { get; set; }
|
||||
|
||||
@ -2448,27 +2448,46 @@ public sealed class StoryStateService(
|
||||
}
|
||||
|
||||
var characterTimeline = await characters.GetTimelineAsync(projectId, null);
|
||||
var assetLocations = await GetAssetLocationsThroughCurrentAsync(context.ScopedScenes);
|
||||
var maxScopedSceneIndex = context.ScopedScenes
|
||||
.Select(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1))
|
||||
.DefaultIfEmpty(-1)
|
||||
.Max();
|
||||
var scenesThroughRange = context.OrderedScenes
|
||||
.Where(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1) <= maxScopedSceneIndex)
|
||||
.ToList();
|
||||
var assetTimeline = await assets.GetTimelineAsync(projectId, null);
|
||||
var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughRange);
|
||||
var custodyCharacters = await assets.ListCustodyCharactersByProjectAsync(projectId);
|
||||
var characterIds = filter.CharacterIDs.ToHashSet();
|
||||
var assetIds = filter.AssetIDs.ToHashSet();
|
||||
var characterAssignments = characterTimeline.Appearances
|
||||
.Where(x => x.LocationID.HasValue)
|
||||
.GroupBy(x => x.CharacterID)
|
||||
.ToDictionary(x => x.Key, x => x.OrderBy(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, -1)).ThenBy(row => row.UpdatedDate).ToList());
|
||||
var assetAssignments = BuildAssetLocationAssignments(assetLocations, custodyCharacters)
|
||||
.GroupBy(x => x.StoryAssetID)
|
||||
.ToDictionary(x => x.Key, x => x.OrderBy(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, -1)).ThenBy(row => row.UpdatedDate).ToList());
|
||||
var result = new List<LocationActivityViewModel>();
|
||||
|
||||
foreach (var scene in context.ScopedScenes)
|
||||
{
|
||||
var sceneIndex = context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1);
|
||||
var sceneCharacters = filter.IncludeCharacters
|
||||
? characterTimeline.Appearances
|
||||
.Where(x => x.SceneID == scene.SceneID && x.LocationID == locationId && (!characterIds.Any() || characterIds.Contains(x.CharacterID)))
|
||||
.Select(x => x.CharacterName)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x)
|
||||
? characterTimeline.Characters
|
||||
.Where(character => !characterIds.Any() || characterIds.Contains(character.CharacterID))
|
||||
.Select(character => BuildCurrentCharacterPresence(character, characterAssignments.GetValueOrDefault(character.CharacterID, []), locationId, scene.SceneID, sceneIndex, context))
|
||||
.Where(x => x is not null)
|
||||
.Select(x => x!)
|
||||
.OrderBy(x => x.EntityName)
|
||||
.ToList()
|
||||
: [];
|
||||
var sceneAssets = filter.IncludeAssets
|
||||
? assetLocations
|
||||
.Where(x => x.SceneID == scene.SceneID && x.LocationID == locationId && (!assetIds.Any() || assetIds.Contains(x.StoryAssetID)))
|
||||
.Select(x => x.AssetName)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x)
|
||||
? assetTimeline.Assets
|
||||
.Where(asset => !assetIds.Any() || assetIds.Contains(asset.StoryAssetID))
|
||||
.Select(asset => BuildCurrentAssetPresence(asset, assetAssignments.GetValueOrDefault(asset.StoryAssetID, []), characterAssignments, locationId, scene.SceneID, sceneIndex, context))
|
||||
.Where(x => x is not null)
|
||||
.Select(x => x!)
|
||||
.OrderBy(x => x.EntityName)
|
||||
.ToList()
|
||||
: [];
|
||||
|
||||
@ -2490,8 +2509,10 @@ public sealed class StoryStateService(
|
||||
SceneTitle = sceneContext?.SceneTitle ?? scene.SceneTitle,
|
||||
SceneLabel = SceneLabel(scene.SceneNumber, scene.SceneTitle),
|
||||
SceneDisplayName = sceneContext?.SceneDisplayName ?? SceneLabel(scene.SceneNumber, scene.SceneTitle),
|
||||
CharactersPresent = sceneCharacters.Any() ? string.Join(", ", sceneCharacters) : "None",
|
||||
AssetsPresent = sceneAssets.Any() ? string.Join(", ", sceneAssets) : "None"
|
||||
Characters = sceneCharacters,
|
||||
Assets = sceneAssets,
|
||||
CharactersPresent = sceneCharacters.Any() ? string.Join(", ", sceneCharacters.Select(x => x.EntityName)) : "None",
|
||||
AssetsPresent = sceneAssets.Any() ? string.Join(", ", sceneAssets.Select(x => x.EntityName)) : "None"
|
||||
});
|
||||
}
|
||||
|
||||
@ -2505,6 +2526,7 @@ public sealed class StoryStateService(
|
||||
var orderedScenes = OrderScenes(timeline).ToList();
|
||||
var scopedScenes = ApplySceneRange(orderedScenes, timeline.Chapters, filter).ToList();
|
||||
return new RangeContext(
|
||||
orderedScenes,
|
||||
scopedScenes,
|
||||
scopedScenes.Select(x => x.SceneID).ToHashSet(),
|
||||
BuildSceneIndexes(orderedScenes),
|
||||
@ -2713,6 +2735,146 @@ public sealed class StoryStateService(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static LocationActivityEntityViewModel? BuildCurrentCharacterPresence(
|
||||
Character character,
|
||||
IReadOnlyList<SceneCharacter> assignments,
|
||||
int locationId,
|
||||
int currentSceneId,
|
||||
int currentSceneIndex,
|
||||
RangeContext context)
|
||||
{
|
||||
var latest = assignments
|
||||
.Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex)
|
||||
.OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
||||
.ThenByDescending(x => x.UpdatedDate)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (latest?.LocationID != locationId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LocationActivityEntityViewModel
|
||||
{
|
||||
EntityID = character.CharacterID,
|
||||
EntityName = character.CharacterName,
|
||||
EntityType = "Character",
|
||||
StateType = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
|
||||
LastConfirmedSceneID = latest.SceneID,
|
||||
LastConfirmedSceneDisplayName = context.SceneLabels.GetValueOrDefault(latest.SceneID, SceneLabel(latest.SceneNumber, latest.SceneTitle))
|
||||
};
|
||||
}
|
||||
|
||||
private static LocationActivityEntityViewModel? BuildCurrentAssetPresence(
|
||||
StoryAsset asset,
|
||||
IReadOnlyList<AssetLocationActivityAssignment> assignments,
|
||||
IReadOnlyDictionary<int, List<SceneCharacter>> characterAssignments,
|
||||
int locationId,
|
||||
int currentSceneId,
|
||||
int currentSceneIndex,
|
||||
RangeContext context)
|
||||
{
|
||||
var latest = assignments
|
||||
.Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex)
|
||||
.OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
||||
.ThenByDescending(x => x.Priority)
|
||||
.ThenByDescending(x => x.UpdatedDate)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (latest is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var note = latest.Note;
|
||||
if (latest.LocationID != locationId)
|
||||
{
|
||||
if (!latest.HolderCharacterIDs.Any())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var holderInLocation = latest.HolderCharacterIDs
|
||||
.Select(holderId => ResolveCharacterLocationAtScene(characterAssignments.GetValueOrDefault(holderId, []), currentSceneIndex, context))
|
||||
.FirstOrDefault(x => x?.LocationID == locationId);
|
||||
|
||||
if (holderInLocation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
note = string.IsNullOrWhiteSpace(holderInLocation.CharacterName)
|
||||
? latest.Note
|
||||
: $"Via {holderInLocation.CharacterName}";
|
||||
}
|
||||
|
||||
return new LocationActivityEntityViewModel
|
||||
{
|
||||
EntityID = asset.StoryAssetID,
|
||||
EntityName = asset.AssetName,
|
||||
EntityType = "Asset",
|
||||
StateType = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
|
||||
LastConfirmedSceneID = latest.SceneID,
|
||||
LastConfirmedSceneDisplayName = context.SceneLabels.GetValueOrDefault(latest.SceneID, "Unknown scene"),
|
||||
Note = note
|
||||
};
|
||||
}
|
||||
|
||||
private static SceneCharacter? ResolveCharacterLocationAtScene(
|
||||
IReadOnlyList<SceneCharacter> assignments,
|
||||
int currentSceneIndex,
|
||||
RangeContext context)
|
||||
{
|
||||
return assignments
|
||||
.Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex)
|
||||
.OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
||||
.ThenByDescending(x => x.UpdatedDate)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static List<AssetLocationActivityAssignment> BuildAssetLocationAssignments(
|
||||
IReadOnlyList<SceneAssetLocation> assetLocations,
|
||||
IReadOnlyList<AssetCustodyCharacter> custodyCharacters)
|
||||
{
|
||||
var locationAssignments = assetLocations.Select(x => new AssetLocationActivityAssignment(
|
||||
x.StoryAssetID,
|
||||
x.SceneID,
|
||||
x.LocationID,
|
||||
[],
|
||||
x.LocationPath,
|
||||
2,
|
||||
x.UpdatedDate,
|
||||
null));
|
||||
var custodyAssignments = custodyCharacters
|
||||
.GroupBy(x => new { x.AssetCustodyEventID, x.StoryAssetID, x.SceneID })
|
||||
.Select(group =>
|
||||
{
|
||||
var first = group.First();
|
||||
var holderIds = group
|
||||
.Select(x => x.CharacterID)
|
||||
.Where(x => x.HasValue)
|
||||
.Select(x => x!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var holderNames = group
|
||||
.Select(x => x.CharacterName ?? x.CharacterNameText)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new AssetLocationActivityAssignment(
|
||||
group.Key.StoryAssetID,
|
||||
group.Key.SceneID,
|
||||
null,
|
||||
holderIds,
|
||||
string.Join(", ", holderNames),
|
||||
3,
|
||||
first.UpdatedDate,
|
||||
holderIds.Any() ? null : "Named holder has no known character location");
|
||||
});
|
||||
|
||||
return locationAssignments.Concat(custodyAssignments).ToList();
|
||||
}
|
||||
|
||||
private static void FilterStoryState(StoryStateViewModel storyState, ContinuityFilter filter)
|
||||
{
|
||||
storyState.Characters = filter.IncludeCharacters
|
||||
@ -2836,7 +2998,18 @@ public sealed class StoryStateService(
|
||||
int Priority,
|
||||
DateTime UpdatedDate);
|
||||
|
||||
private sealed record AssetLocationActivityAssignment(
|
||||
int StoryAssetID,
|
||||
int SceneID,
|
||||
int? LocationID,
|
||||
IReadOnlyList<int> HolderCharacterIDs,
|
||||
string Label,
|
||||
int Priority,
|
||||
DateTime UpdatedDate,
|
||||
string? Note);
|
||||
|
||||
private sealed record RangeContext(
|
||||
IReadOnlyList<Scene> OrderedScenes,
|
||||
IReadOnlyList<Scene> ScopedScenes,
|
||||
IReadOnlySet<int> AllowedSceneIds,
|
||||
IReadOnlyDictionary<int, int> SceneIndexes,
|
||||
|
||||
@ -355,6 +355,19 @@ public sealed class LocationActivityViewModel
|
||||
public string SceneDisplayName { get; set; } = string.Empty;
|
||||
public string CharactersPresent { get; set; } = string.Empty;
|
||||
public string AssetsPresent { get; set; } = string.Empty;
|
||||
public List<LocationActivityEntityViewModel> Characters { get; set; } = [];
|
||||
public List<LocationActivityEntityViewModel> Assets { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class LocationActivityEntityViewModel
|
||||
{
|
||||
public int EntityID { get; set; }
|
||||
public string EntityName { get; set; } = string.Empty;
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public string StateType { get; set; } = string.Empty;
|
||||
public int? LastConfirmedSceneID { get; set; }
|
||||
public string LastConfirmedSceneDisplayName { get; set; } = string.Empty;
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SceneWorkflowEditViewModel
|
||||
|
||||
@ -364,8 +364,54 @@ else
|
||||
<td>@row.ChapterDisplayName</td>
|
||||
}
|
||||
<td>@row.SceneDisplayName</td>
|
||||
<td>@row.CharactersPresent</td>
|
||||
<td>@row.AssetsPresent</td>
|
||||
<td>
|
||||
@if (!row.Characters.Any())
|
||||
{
|
||||
<span class="muted">None</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-grid gap-1">
|
||||
@foreach (var character in row.Characters)
|
||||
{
|
||||
<div>
|
||||
<span>@character.EntityName</span>
|
||||
<span class="badge @StateBadgeClass(character.StateType)">@character.StateType</span>
|
||||
@if (!string.IsNullOrWhiteSpace(character.LastConfirmedSceneDisplayName))
|
||||
{
|
||||
<small class="text-muted d-block">Last confirmed: @character.LastConfirmedSceneDisplayName</small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (!row.Assets.Any())
|
||||
{
|
||||
<span class="muted">None</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-grid gap-1">
|
||||
@foreach (var asset in row.Assets)
|
||||
{
|
||||
<div>
|
||||
<span>@asset.EntityName</span>
|
||||
<span class="badge @StateBadgeClass(asset.StateType)">@asset.StateType</span>
|
||||
@if (!string.IsNullOrWhiteSpace(asset.Note))
|
||||
{
|
||||
<small class="text-muted"> @asset.Note</small>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(asset.LastConfirmedSceneDisplayName))
|
||||
{
|
||||
<small class="text-muted d-block">Last confirmed: @asset.LastConfirmedSceneDisplayName</small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user