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))
+ {
+ OpenCellEditorAsync(groupedCell.ResultId)"
+ @onkeydown="args => HandleCellKeyDownAsync(args, groupedCell.ResultId)">
+
+
+ }
+ 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))
+ {
+ OpenCellEditorAsync(cell.ResultId)"
+ @onkeydown="args => HandleCellKeyDownAsync(args, cell.ResultId)">
+
+
+ }
+ 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)
+{
+
+
+
+
+ @if (!string.IsNullOrWhiteSpace(ErrorMessage))
+ {
+
@ErrorMessage
+ }
+
+ @if (IsLoading)
+ {
+
Loading editor...
+ }
+ else
+ {
+
+
+ Base Cell
+
+ Raw Cell Text
+
+
+
+ Description / Prose
+
+
+
+ Affix Text
+
+
+
+
+
+
+
+ @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];
+
+
+ @EffectFields(effect)
+
+ }
+ }
+
+
+
+
+ @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];
+
+
+
+
+ Condition Text
+
+
+
+ Raw Text
+
+
+
+ Description / Prose
+
+
+
+ Affix Text
+
+
+
+
+
+
+ @if (branch.Effects.Count == 0)
+ {
+
No normalized branch effects.
+ }
+ else
+ {
+ @for (var effectIndex = 0; effectIndex < branch.Effects.Count; effectIndex++)
+ {
+ var effect = branch.Effects[effectIndex];
+
+
+ @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;
+ }
}