diff --git a/README.md b/README.md index d6cdab8..7885cbd 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Starter `.NET 10` Blazor Web App with: - minimal API endpoints for attack and critical table lookup +- manual cell-edit tooling for repairing imported critical-table entries - `EF Core 10` with SQLite - seeded starter data for `Broadsword`, `Short Bow`, `Slash`, and `Puncture` - an interactive Blazor lookup UI @@ -20,7 +21,11 @@ The app creates `rolemaster.db` on first run. - `GET /api/reference-data` - `POST /api/lookup/attack` - `POST /api/lookup/critical` +- `GET /api/tables/critical/{slug}/cells/{resultId}` +- `PUT /api/tables/critical/{slug}/cells/{resultId}` ## Notes +The tables page now supports manual curation of imported critical-table cells. Hovering a populated cell shows that it is editable, and clicking opens a popup editor for the full `CriticalResult` record plus its nested `CriticalBranch` and `CriticalEffect` rows. + The current database is an initial seeded subset designed to prove the full lookup workflow. The existing schema notes in `critical_tables_db_model.md` and `critical_tables_schema.sql` are still useful as the source of truth for expanding the import pipeline and normalizing more of the official tables. diff --git a/docs/critical_tables_db_model.md b/docs/critical_tables_db_model.md index c217833..70e2f14 100644 --- a/docs/critical_tables_db_model.md +++ b/docs/critical_tables_db_model.md @@ -270,3 +270,29 @@ Current import flow: 7. Route image PDFs like `Void.pdf` through OCR before the same parser. The important design decision is: never throw away the original text. The prose is too irregular to rely on normalized fields alone. + +## Manual curation workflow + +Because the import path depends on OCR, PDF XML extraction, and heuristics, the web app now treats manual repair as a first-class capability instead of an out-of-band database operation. + +Current curation flow: + +1. Browse a table on the `/tables` page. +2. Hover a populated cell to identify editable entries. +3. Open the popup editor for that cell. +4. Edit the entire `critical_result` graph: + - base raw cell text + - curated prose / description + - raw affix text + - parse status + - parsed JSON + - nested `critical_branch` rows + - nested `critical_effect` rows for both the base result and branches +5. Save the result back through the API. + +The corresponding API endpoints are: + +- `GET /api/tables/critical/{slug}/cells/{resultId}` +- `PUT /api/tables/critical/{slug}/cells/{resultId}` + +The save operation replaces the stored branches and effects for that cell with the submitted payload. That keeps manual edits deterministic and avoids trying to reconcile partial child-row diffs against importer-generated data. diff --git a/src/RolemasterDb.App/Components/Pages/Api.razor b/src/RolemasterDb.App/Components/Pages/Api.razor index b77616a..3040743 100644 --- a/src/RolemasterDb.App/Components/Pages/Api.razor +++ b/src/RolemasterDb.App/Components/Pages/Api.razor @@ -4,8 +4,8 @@
Minimal API -

Endpoints for attack and critical lookups.

-

The Blazor UI uses the same lookup service that the API exposes, so this page doubles as the first integration contract.

+

Endpoints for attack, critical lookup, and manual critical-table curation.

+

The Blazor UI uses the same lookup service that the API exposes, so this page doubles as the first integration contract for both read and write workflows.

@@ -50,4 +50,55 @@ }

Response now includes table metadata, roll-band bounds, raw imported cell text, parse status, and parsed JSON alongside the gameplay description.

+ +
+

Cell editor load

+

GET /api/tables/critical/{slug}/cells/{resultId}

+
{
+  "resultId": 412,
+  "tableSlug": "slash",
+  "tableName": "Slash Critical Strike Table",
+  "rollBand": "66-70",
+  "groupKey": null,
+  "columnKey": "C",
+  "rawCellText": "Original imported full cell text",
+  "descriptionText": "Current curated prose",
+  "rawAffixText": "+8H - 2S",
+  "parseStatus": "verified",
+  "parsedJson": "{}",
+  "effects": [],
+  "branches": []
+}
+

Use this to retrieve the full editable result graph for one critical-table cell, including nested branches and normalized effects.

+
+ +
+

Cell editor save

+

PUT /api/tables/critical/{slug}/cells/{resultId}

+
{
+  "rawCellText": "Corrected imported text",
+  "descriptionText": "Rewritten prose after manual review",
+  "rawAffixText": "+10H - must parry 2 rnds",
+  "parseStatus": "manually_curated",
+  "parsedJson": "{\"reviewed\":true}",
+  "effects": [
+    {
+      "effectCode": "direct_hits",
+      "target": null,
+      "valueInteger": 10,
+      "valueDecimal": null,
+      "valueExpression": null,
+      "durationRounds": null,
+      "perRound": null,
+      "modifier": null,
+      "bodyPart": null,
+      "isPermanent": false,
+      "sourceType": "symbol",
+      "sourceText": "+10H"
+    }
+  ],
+  "branches": []
+}
+

The save endpoint replaces the stored base result, branch rows, and effect rows for that cell with the submitted curated payload.

+
diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor index a0bb77b..a55dfc3 100644 --- a/src/RolemasterDb.App/Components/Pages/Tables.razor +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -117,9 +117,26 @@ { foreach (var column in detail.Columns) { - - @RenderCell(rollBand.Label, group.Key, column.Key) - + @if (TryGetCell(rollBand.Label, group.Key, column.Key, out var groupedCell)) + { + + + + } + else + { + + + + } } } } @@ -127,9 +144,26 @@ { foreach (var column in detail.Columns) { - - @RenderCell(rollBand.Label, null, column.Key) - + @if (TryGetCell(rollBand.Label, null, column.Key, out var cell)) + { + + + + } + else + { + + + + } } } @@ -164,6 +198,17 @@ } +@if (isEditorOpen) +{ + +} + @code { private LookupReferenceData? referenceData; private CriticalTableDetail? tableDetail; @@ -175,6 +220,12 @@ private int tableLayoutVersion; private int appliedLayoutVersion = -1; private bool IsTableSelectionDisabled => isReferenceDataLoading || (referenceData?.CriticalTables.Count ?? 0) == 0; + private bool isEditorOpen; + private bool isEditorLoading; + private bool isEditorSaving; + private string? editorError; + private int? editingResultId; + private CriticalCellEditorModel? editorModel; protected override async Task OnInitializedAsync() { @@ -252,25 +303,6 @@ } } - private RenderFragment RenderCell(string rollBand, string? groupKey, string columnKey) => builder => - { - if (TryGetCell(rollBand, groupKey, columnKey, out var cell)) - { - builder.OpenComponent(0, typeof(CompactCriticalCell)); - builder.AddAttribute(1, "Description", cell.Description ?? string.Empty); - builder.AddAttribute(2, "Effects", cell.Effects ?? Array.Empty()); - builder.AddAttribute(3, "Branches", cell.Branches ?? Array.Empty()); - builder.CloseComponent(); - } - else - { - builder.OpenElement(4, "span"); - builder.AddAttribute(5, "class", "empty-cell"); - builder.AddContent(6, "—"); - builder.CloseElement(); - } - }; - private bool TryGetCell(string rollBand, string? groupKey, string columnKey, [NotNullWhen(true)] out CriticalTableCellDetail? cell) { if (cellIndex is null) @@ -281,4 +313,91 @@ return cellIndex.TryGetValue((rollBand, groupKey, columnKey), out cell); } + + private async Task OpenCellEditorAsync(int resultId) + { + if (string.IsNullOrWhiteSpace(selectedTableSlug)) + { + return; + } + + isEditorOpen = true; + isEditorLoading = true; + isEditorSaving = false; + editorError = null; + editingResultId = resultId; + + try + { + var response = await LookupService.GetCriticalCellEditorAsync(selectedTableSlug, resultId); + if (response is null) + { + editorError = "The selected cell could not be loaded for editing."; + editorModel = null; + return; + } + + editorModel = CriticalCellEditorModel.FromResponse(response); + } + catch (Exception exception) + { + editorError = exception.Message; + editorModel = null; + } + finally + { + isEditorLoading = false; + } + } + + private async Task CloseCellEditorAsync() + { + isEditorOpen = false; + isEditorLoading = false; + isEditorSaving = false; + editorError = null; + editingResultId = null; + editorModel = null; + await InvokeAsync(StateHasChanged); + } + + private async Task SaveCellEditorAsync() + { + if (editorModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || editingResultId is null) + { + return; + } + + isEditorSaving = true; + editorError = null; + + try + { + var response = await LookupService.UpdateCriticalCellAsync(selectedTableSlug, editingResultId.Value, editorModel.ToRequest()); + if (response is null) + { + editorError = "The selected cell could not be saved."; + return; + } + + editorModel = CriticalCellEditorModel.FromResponse(response); + await LoadTableDetailAsync(); + } + catch (Exception exception) + { + editorError = exception.Message; + } + finally + { + isEditorSaving = false; + } + } + + private async Task HandleCellKeyDownAsync(KeyboardEventArgs args, int resultId) + { + if (args.Key is "Enter" or " ") + { + await OpenCellEditorAsync(resultId); + } + } } diff --git a/src/RolemasterDb.App/Components/Shared/CriticalBranchEditorModel.cs b/src/RolemasterDb.App/Components/Shared/CriticalBranchEditorModel.cs new file mode 100644 index 0000000..20a8f31 --- /dev/null +++ b/src/RolemasterDb.App/Components/Shared/CriticalBranchEditorModel.cs @@ -0,0 +1,45 @@ +using RolemasterDb.App.Features; + +namespace RolemasterDb.App.Components.Shared; + +public sealed class CriticalBranchEditorModel +{ + public string BranchKind { get; set; } = "conditional"; + public string? ConditionKey { get; set; } + public string ConditionText { get; set; } = string.Empty; + public string ConditionJson { get; set; } = "{}"; + public string RawText { get; set; } = string.Empty; + public string DescriptionText { get; set; } = string.Empty; + public string? RawAffixText { get; set; } + public string ParsedJson { get; set; } = "{}"; + public int SortOrder { get; set; } + public List Effects { get; set; } = []; + + public static CriticalBranchEditorModel FromItem(CriticalBranchEditorItem item) => + new() + { + BranchKind = item.BranchKind, + ConditionKey = item.ConditionKey, + ConditionText = item.ConditionText, + ConditionJson = item.ConditionJson, + RawText = item.RawText, + DescriptionText = item.DescriptionText, + RawAffixText = item.RawAffixText, + ParsedJson = item.ParsedJson, + SortOrder = item.SortOrder, + Effects = item.Effects.Select(CriticalEffectEditorModel.FromItem).ToList() + }; + + public CriticalBranchEditorItem ToItem() => + new( + BranchKind, + ConditionKey, + ConditionText, + ConditionJson, + RawText, + DescriptionText, + RawAffixText, + ParsedJson, + SortOrder, + Effects.Select(effect => effect.ToItem()).ToList()); +} diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor new file mode 100644 index 0000000..d7a3be4 --- /dev/null +++ b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor @@ -0,0 +1,315 @@ +@if (Model is not null) +{ +
+
+
+
+ @Model.SourceDocument +

Edit @Model.TableName

+

+ Roll band @Model.RollBand, column @Model.ColumnLabel + @if (!string.IsNullOrWhiteSpace(Model.GroupLabel)) + { + , group @Model.GroupLabel + } +

+
+ +
+ + @if (!string.IsNullOrWhiteSpace(ErrorMessage)) + { +

@ErrorMessage

+ } + + @if (IsLoading) + { +

Loading editor...

+ } + else + { +
+
+

Base Cell

+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+

Base Effects

+ +
+ @if (Model.Effects.Count == 0) + { +

No normalized base effects for this cell.

+ } + else + { + @for (var index = 0; index < Model.Effects.Count; index++) + { + var effect = Model.Effects[index]; +
+
+ Effect @(index + 1) + +
+ @EffectFields(effect) +
+ } + } +
+ +
+
+

Branches

+ +
+ @if (Model.Branches.Count == 0) + { +

No branch records on this cell.

+ } + else + { + @for (var index = 0; index < Model.Branches.Count; index++) + { + var branch = Model.Branches[index]; +
+
+ Branch @(index + 1) + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+
Branch Effects
+ +
+ @if (branch.Effects.Count == 0) + { +

No normalized branch effects.

+ } + else + { + @for (var effectIndex = 0; effectIndex < branch.Effects.Count; effectIndex++) + { + var effect = branch.Effects[effectIndex]; +
+
+ Branch Effect @(effectIndex + 1) + +
+ @EffectFields(effect) +
+ } + } +
+
+ } + } +
+
+ } + +
+ + +
+
+
+} + +@code { + [Parameter, EditorRequired] + public CriticalCellEditorModel? Model { get; set; } + + [Parameter] + public bool IsLoading { get; set; } + + [Parameter] + public bool IsSaving { get; set; } + + [Parameter] + public string? ErrorMessage { get; set; } + + [Parameter, EditorRequired] + public EventCallback OnClose { get; set; } + + [Parameter, EditorRequired] + public EventCallback OnSave { get; set; } + + private async Task HandleBackdropClicked() + { + await OnClose.InvokeAsync(); + } + + private void AddBaseEffect() + { + Model?.Effects.Add(new CriticalEffectEditorModel()); + } + + private void RemoveBaseEffect(int index) + { + if (Model is null || index < 0 || index >= Model.Effects.Count) + { + return; + } + + Model.Effects.RemoveAt(index); + } + + private void AddBranch() + { + if (Model is null) + { + return; + } + + Model.Branches.Add(new CriticalBranchEditorModel + { + SortOrder = Model.Branches.Count + 1 + }); + } + + private void RemoveBranch(int index) + { + if (Model is null || index < 0 || index >= Model.Branches.Count) + { + return; + } + + Model.Branches.RemoveAt(index); + } + + private static void AddBranchEffect(CriticalBranchEditorModel branch) + { + branch.Effects.Add(new CriticalEffectEditorModel()); + } + + private static void RemoveBranchEffect(CriticalBranchEditorModel branch, int index) + { + if (index < 0 || index >= branch.Effects.Count) + { + return; + } + + branch.Effects.RemoveAt(index); + } +} + +@functions { + private RenderFragment EffectFields(CriticalEffectEditorModel effect) => @
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
; +} diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs new file mode 100644 index 0000000..b6859ba --- /dev/null +++ b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs @@ -0,0 +1,63 @@ +using RolemasterDb.App.Features; + +namespace RolemasterDb.App.Components.Shared; + +public sealed class CriticalCellEditorModel +{ + public int ResultId { get; set; } + public string TableSlug { get; set; } = string.Empty; + public string TableName { get; set; } = string.Empty; + public string SourceDocument { get; set; } = string.Empty; + public string RollBand { get; set; } = string.Empty; + public string? GroupKey { get; set; } + public string? GroupLabel { get; set; } + public string ColumnKey { get; set; } = string.Empty; + public string ColumnLabel { get; set; } = string.Empty; + public string ColumnRole { get; set; } = string.Empty; + public string RawCellText { get; set; } = string.Empty; + public string DescriptionText { get; set; } = string.Empty; + public string? RawAffixText { get; set; } + public string ParseStatus { get; set; } = string.Empty; + public string ParsedJson { get; set; } = "{}"; + public List Effects { get; set; } = []; + public List Branches { get; set; } = []; + + public static CriticalCellEditorModel FromResponse(CriticalCellEditorResponse response) => + new() + { + ResultId = response.ResultId, + TableSlug = response.TableSlug, + TableName = response.TableName, + SourceDocument = response.SourceDocument, + RollBand = response.RollBand, + GroupKey = response.GroupKey, + GroupLabel = response.GroupLabel, + ColumnKey = response.ColumnKey, + ColumnLabel = response.ColumnLabel, + ColumnRole = response.ColumnRole, + RawCellText = response.RawCellText, + DescriptionText = response.DescriptionText, + RawAffixText = response.RawAffixText, + ParseStatus = response.ParseStatus, + ParsedJson = response.ParsedJson, + Effects = response.Effects.Select(CriticalEffectEditorModel.FromItem).ToList(), + Branches = response.Branches.Select(CriticalBranchEditorModel.FromItem).ToList() + }; + + public CriticalCellUpdateRequest ToRequest() => + new( + RawCellText, + DescriptionText, + RawAffixText, + ParseStatus, + ParsedJson, + Effects.Select(effect => effect.ToItem()).ToList(), + Branches + .OrderBy(branch => branch.SortOrder) + .Select((branch, index) => + { + branch.SortOrder = index + 1; + return branch.ToItem(); + }) + .ToList()); +} diff --git a/src/RolemasterDb.App/Components/Shared/CriticalEffectEditorModel.cs b/src/RolemasterDb.App/Components/Shared/CriticalEffectEditorModel.cs new file mode 100644 index 0000000..e402100 --- /dev/null +++ b/src/RolemasterDb.App/Components/Shared/CriticalEffectEditorModel.cs @@ -0,0 +1,51 @@ +using RolemasterDb.App.Features; + +namespace RolemasterDb.App.Components.Shared; + +public sealed class CriticalEffectEditorModel +{ + public string EffectCode { get; set; } = string.Empty; + public string? Target { get; set; } + public int? ValueInteger { get; set; } + public decimal? ValueDecimal { get; set; } + public string? ValueExpression { get; set; } + public int? DurationRounds { get; set; } + public int? PerRound { get; set; } + public int? Modifier { get; set; } + public string? BodyPart { get; set; } + public bool IsPermanent { get; set; } + public string SourceType { get; set; } = "symbol"; + public string? SourceText { get; set; } + + public static CriticalEffectEditorModel FromItem(CriticalEffectEditorItem item) => + new() + { + EffectCode = item.EffectCode, + Target = item.Target, + ValueInteger = item.ValueInteger, + ValueDecimal = item.ValueDecimal, + ValueExpression = item.ValueExpression, + DurationRounds = item.DurationRounds, + PerRound = item.PerRound, + Modifier = item.Modifier, + BodyPart = item.BodyPart, + IsPermanent = item.IsPermanent, + SourceType = item.SourceType, + SourceText = item.SourceText + }; + + public CriticalEffectEditorItem ToItem() => + new( + EffectCode, + Target, + ValueInteger, + ValueDecimal, + ValueExpression, + DurationRounds, + PerRound, + Modifier, + BodyPart, + IsPermanent, + SourceType, + SourceText); +} diff --git a/src/RolemasterDb.App/Features/CriticalBranchEditorItem.cs b/src/RolemasterDb.App/Features/CriticalBranchEditorItem.cs new file mode 100644 index 0000000..5b7c457 --- /dev/null +++ b/src/RolemasterDb.App/Features/CriticalBranchEditorItem.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace RolemasterDb.App.Features; + +public sealed record CriticalBranchEditorItem( + string BranchKind, + string? ConditionKey, + string ConditionText, + string ConditionJson, + string RawText, + string DescriptionText, + string? RawAffixText, + string ParsedJson, + int SortOrder, + IReadOnlyList Effects); diff --git a/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs b/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs new file mode 100644 index 0000000..c9c9775 --- /dev/null +++ b/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace RolemasterDb.App.Features; + +public sealed record CriticalCellEditorResponse( + int ResultId, + string TableSlug, + string TableName, + string SourceDocument, + string RollBand, + string? GroupKey, + string? GroupLabel, + string ColumnKey, + string ColumnLabel, + string ColumnRole, + string RawCellText, + string DescriptionText, + string? RawAffixText, + string ParseStatus, + string ParsedJson, + IReadOnlyList Effects, + IReadOnlyList Branches); diff --git a/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs b/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs new file mode 100644 index 0000000..ffd63bf --- /dev/null +++ b/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace RolemasterDb.App.Features; + +public sealed record CriticalCellUpdateRequest( + string RawCellText, + string DescriptionText, + string? RawAffixText, + string ParseStatus, + string ParsedJson, + IReadOnlyList Effects, + IReadOnlyList Branches); diff --git a/src/RolemasterDb.App/Features/CriticalEffectEditorItem.cs b/src/RolemasterDb.App/Features/CriticalEffectEditorItem.cs new file mode 100644 index 0000000..6195312 --- /dev/null +++ b/src/RolemasterDb.App/Features/CriticalEffectEditorItem.cs @@ -0,0 +1,15 @@ +namespace RolemasterDb.App.Features; + +public sealed record CriticalEffectEditorItem( + string EffectCode, + string? Target, + int? ValueInteger, + decimal? ValueDecimal, + string? ValueExpression, + int? DurationRounds, + int? PerRound, + int? Modifier, + string? BodyPart, + bool IsPermanent, + string SourceType, + string? SourceText); diff --git a/src/RolemasterDb.App/Features/LookupContracts.cs b/src/RolemasterDb.App/Features/LookupContracts.cs index 3ff3b24..f655907 100644 --- a/src/RolemasterDb.App/Features/LookupContracts.cs +++ b/src/RolemasterDb.App/Features/LookupContracts.cs @@ -96,6 +96,7 @@ public sealed record AttackLookupResponse( CriticalLookupResponse? AutoCritical); public sealed record CriticalTableCellDetail( + int ResultId, string RollBand, string ColumnKey, string ColumnLabel, diff --git a/src/RolemasterDb.App/Features/LookupService.cs b/src/RolemasterDb.App/Features/LookupService.cs index f6accef..e660692 100644 --- a/src/RolemasterDb.App/Features/LookupService.cs +++ b/src/RolemasterDb.App/Features/LookupService.cs @@ -231,6 +231,7 @@ public sealed class LookupService(IDbContextFactory dbConte .ThenBy(result => result.CriticalGroup?.SortOrder ?? 0) .ThenBy(result => result.CriticalColumn.SortOrder) .Select(result => new CriticalTableCellDetail( + result.Id, result.CriticalRollBand.Label, result.CriticalColumn.ColumnKey, result.CriticalColumn.Label, @@ -262,6 +263,70 @@ public sealed class LookupService(IDbContextFactory dbConte cells, legend); } + + public async Task GetCriticalCellEditorAsync(string slug, int resultId, CancellationToken cancellationToken = default) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + var normalizedSlug = NormalizeSlug(slug); + var result = await dbContext.CriticalResults + .AsNoTracking() + .AsSplitQuery() + .Include(item => item.CriticalTable) + .Include(item => item.CriticalColumn) + .Include(item => item.CriticalGroup) + .Include(item => item.CriticalRollBand) + .Include(item => item.Effects) + .Include(item => item.Branches) + .ThenInclude(branch => branch.Effects) + .SingleOrDefaultAsync( + item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug, + cancellationToken); + + return result is null ? null : CreateCellEditorResponse(result); + } + + public async Task UpdateCriticalCellAsync( + string slug, + int resultId, + CriticalCellUpdateRequest request, + CancellationToken cancellationToken = default) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + var normalizedSlug = NormalizeSlug(slug); + var result = await dbContext.CriticalResults + .AsSplitQuery() + .Include(item => item.CriticalTable) + .Include(item => item.CriticalColumn) + .Include(item => item.CriticalGroup) + .Include(item => item.CriticalRollBand) + .Include(item => item.Effects) + .Include(item => item.Branches) + .ThenInclude(branch => branch.Effects) + .SingleOrDefaultAsync( + item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug, + cancellationToken); + + if (result is null) + { + return null; + } + + result.RawCellText = request.RawCellText.Trim(); + result.DescriptionText = request.DescriptionText.Trim(); + result.RawAffixText = NormalizeOptionalText(request.RawAffixText); + result.ParseStatus = request.ParseStatus.Trim(); + result.ParsedJson = string.IsNullOrWhiteSpace(request.ParsedJson) ? "{}" : request.ParsedJson.Trim(); + + ReplaceBaseEffects(dbContext, result, request.Effects); + ReplaceBranches(dbContext, result, request.Branches); + + await dbContext.SaveChangesAsync(cancellationToken); + + return CreateCellEditorResponse(result); + } + private static IReadOnlyList BuildLegend(IReadOnlyList cells) { var seenCodes = new HashSet(StringComparer.Ordinal); @@ -320,6 +385,32 @@ public sealed class LookupService(IDbContextFactory dbConte effect.SourceType, effect.SourceText); + private static CriticalCellEditorResponse CreateCellEditorResponse(CriticalResult result) => + new( + result.Id, + result.CriticalTable.Slug, + result.CriticalTable.DisplayName, + result.CriticalTable.SourceDocument, + result.CriticalRollBand.Label, + result.CriticalGroup?.GroupKey, + result.CriticalGroup?.Label, + result.CriticalColumn.ColumnKey, + result.CriticalColumn.Label, + result.CriticalColumn.Role, + result.RawCellText, + result.DescriptionText, + result.RawAffixText, + result.ParseStatus, + result.ParsedJson, + result.Effects + .OrderBy(effect => effect.Id) + .Select(CreateEffectEditorItem) + .ToList(), + result.Branches + .OrderBy(branch => branch.SortOrder) + .Select(CreateBranchEditorItem) + .ToList()); + private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) => new( branch.BranchKind, @@ -334,6 +425,108 @@ public sealed class LookupService(IDbContextFactory dbConte branch.RawText, branch.SortOrder); + private static CriticalBranchEditorItem CreateBranchEditorItem(CriticalBranch branch) => + new( + branch.BranchKind, + branch.ConditionKey, + branch.ConditionText, + branch.ConditionJson, + branch.RawText, + branch.DescriptionText, + branch.RawAffixText, + branch.ParsedJson, + branch.SortOrder, + (branch.Effects ?? Enumerable.Empty()) + .OrderBy(effect => effect.Id) + .Select(CreateEffectEditorItem) + .ToList()); + + private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect) => + new( + effect.EffectCode, + effect.Target, + effect.ValueInteger, + effect.ValueDecimal, + effect.ValueExpression, + effect.DurationRounds, + effect.PerRound, + effect.Modifier, + effect.BodyPart, + effect.IsPermanent, + effect.SourceType, + effect.SourceText); + + private static void ReplaceBaseEffects( + RolemasterDbContext dbContext, + CriticalResult result, + IReadOnlyList? effects) + { + dbContext.CriticalEffects.RemoveRange(result.Effects); + result.Effects.Clear(); + + foreach (var effect in effects ?? Array.Empty()) + { + result.Effects.Add(CreateEffectEntity(effect)); + } + } + + private static void ReplaceBranches( + RolemasterDbContext dbContext, + CriticalResult result, + IReadOnlyList? branches) + { + foreach (var branch in result.Branches) + { + dbContext.CriticalEffects.RemoveRange(branch.Effects); + } + + dbContext.CriticalBranches.RemoveRange(result.Branches); + result.Branches.Clear(); + + foreach (var branch in branches ?? Array.Empty()) + { + var branchEntity = new CriticalBranch + { + BranchKind = branch.BranchKind.Trim(), + ConditionKey = NormalizeOptionalText(branch.ConditionKey), + ConditionText = branch.ConditionText.Trim(), + ConditionJson = string.IsNullOrWhiteSpace(branch.ConditionJson) ? "{}" : branch.ConditionJson.Trim(), + RawText = branch.RawText.Trim(), + DescriptionText = branch.DescriptionText.Trim(), + RawAffixText = NormalizeOptionalText(branch.RawAffixText), + ParsedJson = string.IsNullOrWhiteSpace(branch.ParsedJson) ? "{}" : branch.ParsedJson.Trim(), + SortOrder = branch.SortOrder + }; + + foreach (var effect in branch.Effects ?? Array.Empty()) + { + branchEntity.Effects.Add(CreateEffectEntity(effect)); + } + + result.Branches.Add(branchEntity); + } + } + + private static CriticalEffect CreateEffectEntity(CriticalEffectEditorItem effect) => + new() + { + EffectCode = effect.EffectCode.Trim(), + Target = NormalizeOptionalText(effect.Target), + ValueInteger = effect.ValueInteger, + ValueDecimal = effect.ValueDecimal, + ValueExpression = NormalizeOptionalText(effect.ValueExpression), + DurationRounds = effect.DurationRounds, + PerRound = effect.PerRound, + Modifier = effect.Modifier, + BodyPart = NormalizeOptionalText(effect.BodyPart), + IsPermanent = effect.IsPermanent, + SourceType = effect.SourceType.Trim(), + SourceText = NormalizeOptionalText(effect.SourceText) + }; + + private static string? NormalizeOptionalText(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static string NormalizeSlug(string value) => value.Trim().Replace(' ', '_').ToLowerInvariant(); } diff --git a/src/RolemasterDb.App/Program.cs b/src/RolemasterDb.App/Program.cs index 8fd8e6f..b25ba8d 100644 --- a/src/RolemasterDb.App/Program.cs +++ b/src/RolemasterDb.App/Program.cs @@ -35,6 +35,16 @@ api.MapPost("/lookup/critical", async (CriticalLookupRequest request, LookupServ var result = await lookupService.LookupCriticalAsync(request, cancellationToken); return result is null ? Results.NotFound() : Results.Ok(result); }); +api.MapGet("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, int resultId, LookupService lookupService, CancellationToken cancellationToken) => +{ + var result = await lookupService.GetCriticalCellEditorAsync(slug, resultId, cancellationToken); + return result is null ? Results.NotFound() : Results.Ok(result); +}); +api.MapPut("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, int resultId, CriticalCellUpdateRequest request, LookupService lookupService, CancellationToken cancellationToken) => +{ + var result = await lookupService.UpdateCriticalCellAsync(slug, resultId, request, cancellationToken); + return result is null ? Results.NotFound() : Results.Ok(result); +}); app.MapRazorComponents() .AddInteractiveServerRenderMode(); diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css index aa7c47d..eb27736 100644 --- a/src/RolemasterDb.App/wwwroot/app.css +++ b/src/RolemasterDb.App/wwwroot/app.css @@ -540,6 +540,22 @@ textarea { padding: 0; } +.critical-table-cell { + position: relative; +} + +.critical-table-cell.is-editable { + cursor: pointer; + transition: background-color 0.16s ease, box-shadow 0.16s ease; +} + +.critical-table-cell.is-editable:hover, +.critical-table-cell.is-editable:focus-visible { + background: rgba(255, 248, 232, 0.98); + box-shadow: inset 0 0 0 2px rgba(184, 121, 59, 0.4); + outline: none; +} + .critical-table td .critical-cell { display: flex; flex-direction: column; @@ -600,6 +616,111 @@ textarea { font-size: 0.95rem; } +.critical-editor-backdrop { + position: fixed; + inset: 0; + z-index: 1050; + display: flex; + justify-content: center; + align-items: stretch; + padding: 1.25rem; + background: rgba(38, 26, 20, 0.44); + backdrop-filter: blur(4px); +} + +.critical-editor-dialog { + width: min(1100px, 100%); + max-height: 100%; + display: flex; + flex-direction: column; + border-radius: 24px; + background: rgba(255, 250, 242, 0.98); + border: 1px solid rgba(127, 96, 55, 0.18); + box-shadow: 0 28px 60px rgba(41, 22, 11, 0.24); +} + +.critical-editor-header, +.critical-editor-footer { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid rgba(127, 96, 55, 0.12); +} + +.critical-editor-footer { + border-top: 1px solid rgba(127, 96, 55, 0.12); + border-bottom: none; + align-items: center; +} + +.critical-editor-close { + white-space: nowrap; +} + +.critical-editor-meta { + margin-bottom: 0; +} + +.critical-editor-body { + overflow: auto; + padding: 1rem 1.25rem 1.25rem; + display: grid; + gap: 1rem; +} + +.critical-editor-section, +.critical-editor-subsection { + display: grid; + gap: 0.85rem; +} + +.critical-editor-section h4, +.critical-editor-subsection h5 { + margin: 0; +} + +.critical-editor-section-header, +.critical-editor-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.critical-editor-card { + display: grid; + gap: 0.85rem; + padding: 0.9rem; + border-radius: 16px; + background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(127, 96, 55, 0.15); +} + +.critical-editor-card.nested { + background: rgba(252, 248, 238, 0.96); +} + +.critical-editor-textarea { + min-height: 7rem; +} + +.critical-editor-textarea.compact { + min-height: 4.5rem; +} + +.critical-editor-textarea.tall { + min-height: 10rem; +} + +.critical-editor-checkbox { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: #5d4429; +} + @media (max-width: 640.98px) { .content-shell { padding: 1rem; @@ -609,4 +730,18 @@ textarea { .panel { border-radius: 20px; } + + .critical-editor-backdrop { + padding: 0.65rem; + } + + .critical-editor-dialog { + width: 100%; + } + + .critical-editor-header, + .critical-editor-footer { + flex-direction: column; + align-items: stretch; + } }