Add manual critical table cell editor

This commit is contained in:
2026-03-14 15:09:16 +01:00
parent 4e518244a2
commit 6e28ad975f
16 changed files with 1105 additions and 27 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -4,8 +4,8 @@
<section class="hero-panel">
<span class="eyebrow">Minimal API</span>
<h1 class="page-title">Endpoints for attack and critical lookups.</h1>
<p class="lede">The Blazor UI uses the same lookup service that the API exposes, so this page doubles as the first integration contract.</p>
<h1 class="page-title">Endpoints for attack, critical lookup, and manual critical-table curation.</h1>
<p class="lede">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.</p>
</section>
<div class="api-grid">
@@ -50,4 +50,55 @@
}</pre>
<p class="panel-copy">Response now includes table metadata, roll-band bounds, raw imported cell text, parse status, and parsed JSON alongside the gameplay description.</p>
</section>
<section class="panel">
<h2 class="panel-title">Cell editor load</h2>
<p class="panel-copy"><code>GET /api/tables/critical/{slug}/cells/{resultId}</code></p>
<pre class="code-block">{
"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": []
}</pre>
<p class="panel-copy">Use this to retrieve the full editable result graph for one critical-table cell, including nested branches and normalized effects.</p>
</section>
<section class="panel">
<h2 class="panel-title">Cell editor save</h2>
<p class="panel-copy"><code>PUT /api/tables/critical/{slug}/cells/{resultId}</code></p>
<pre class="code-block">{
"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": []
}</pre>
<p class="panel-copy">The save endpoint replaces the stored base result, branch rows, and effect rows for that cell with the submitted curated payload.</p>
</section>
</div>

View File

@@ -117,20 +117,54 @@
{
foreach (var column in detail.Columns)
{
<td>
@RenderCell(rollBand.Label, group.Key, column.Key)
@if (TryGetCell(rollBand.Label, group.Key, column.Key, out var groupedCell))
{
<td
class="critical-table-cell is-editable"
tabindex="0"
title="Click to edit this cell"
@onclick="() => OpenCellEditorAsync(groupedCell.ResultId)"
@onkeydown="args => HandleCellKeyDownAsync(args, groupedCell.ResultId)">
<CompactCriticalCell
Description="@(groupedCell.Description ?? string.Empty)"
Effects="@(groupedCell.Effects ?? Array.Empty<CriticalEffectLookupResponse>())"
Branches="@(groupedCell.Branches ?? Array.Empty<CriticalBranchLookupResponse>())" />
</td>
}
else
{
<td class="critical-table-cell">
<span class="empty-cell">—</span>
</td>
}
}
}
}
else
{
foreach (var column in detail.Columns)
{
<td>
@RenderCell(rollBand.Label, null, column.Key)
@if (TryGetCell(rollBand.Label, null, column.Key, out var cell))
{
<td
class="critical-table-cell is-editable"
tabindex="0"
title="Click to edit this cell"
@onclick="() => OpenCellEditorAsync(cell.ResultId)"
@onkeydown="args => HandleCellKeyDownAsync(args, cell.ResultId)">
<CompactCriticalCell
Description="@(cell.Description ?? string.Empty)"
Effects="@(cell.Effects ?? Array.Empty<CriticalEffectLookupResponse>())"
Branches="@(cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>())" />
</td>
}
else
{
<td class="critical-table-cell">
<span class="empty-cell">—</span>
</td>
}
}
}
</tr>
}
@@ -164,6 +198,17 @@
}
</section>
@if (isEditorOpen)
{
<CriticalCellEditorDialog
Model="editorModel"
IsLoading="isEditorLoading"
IsSaving="isEditorSaving"
ErrorMessage="editorError"
OnClose="CloseCellEditorAsync"
OnSave="SaveCellEditorAsync" />
}
@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<CriticalEffectLookupResponse>());
builder.AddAttribute(3, "Branches", cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>());
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);
}
}
}

View File

@@ -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<CriticalEffectEditorModel> 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());
}

View File

@@ -0,0 +1,315 @@
@if (Model is not null)
{
<div class="critical-editor-backdrop" @onclick="HandleBackdropClicked">
<div class="critical-editor-dialog" @onclick:stopPropagation="true">
<header class="critical-editor-header">
<div>
<span class="eyebrow">@Model.SourceDocument</span>
<h3 class="panel-title">Edit @Model.TableName</h3>
<p class="muted critical-editor-meta">
Roll band <strong>@Model.RollBand</strong>, column <strong>@Model.ColumnLabel</strong>
@if (!string.IsNullOrWhiteSpace(Model.GroupLabel))
{
<span>, group <strong>@Model.GroupLabel</strong></span>
}
</p>
</div>
<button type="button" class="btn btn-link critical-editor-close" @onclick="OnClose">Close</button>
</header>
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
{
<p class="error-text">@ErrorMessage</p>
}
@if (IsLoading)
{
<p class="muted">Loading editor...</p>
}
else
{
<div class="critical-editor-body">
<section class="critical-editor-section">
<h4>Base Cell</h4>
<div class="field-shell">
<label>Raw Cell Text</label>
<InputTextArea class="input-shell critical-editor-textarea tall" @bind-Value="Model.RawCellText" />
</div>
<div class="field-shell">
<label>Description / Prose</label>
<InputTextArea class="input-shell critical-editor-textarea" @bind-Value="Model.DescriptionText" />
</div>
<div class="field-shell">
<label>Affix Text</label>
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="Model.RawAffixText" />
</div>
<div class="form-grid">
<div class="field-shell">
<label>Parse Status</label>
<InputText class="input-shell" @bind-Value="Model.ParseStatus" />
</div>
<div class="field-shell">
<label>Parsed Json</label>
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="Model.ParsedJson" />
</div>
</div>
</section>
<section class="critical-editor-section">
<div class="critical-editor-section-header">
<h4>Base Effects</h4>
<button type="button" class="btn-ritual" @onclick="AddBaseEffect">Add Effect</button>
</div>
@if (Model.Effects.Count == 0)
{
<p class="muted">No normalized base effects for this cell.</p>
}
else
{
@for (var index = 0; index < Model.Effects.Count; index++)
{
var effect = Model.Effects[index];
<div class="critical-editor-card">
<div class="critical-editor-card-header">
<strong>Effect @(index + 1)</strong>
<button type="button" class="btn btn-link" @onclick="() => RemoveBaseEffect(index)">Remove</button>
</div>
@EffectFields(effect)
</div>
}
}
</section>
<section class="critical-editor-section">
<div class="critical-editor-section-header">
<h4>Branches</h4>
<button type="button" class="btn-ritual" @onclick="AddBranch">Add Branch</button>
</div>
@if (Model.Branches.Count == 0)
{
<p class="muted">No branch records on this cell.</p>
}
else
{
@for (var index = 0; index < Model.Branches.Count; index++)
{
var branch = Model.Branches[index];
<div class="critical-editor-card branch-card-editor">
<div class="critical-editor-card-header">
<strong>Branch @(index + 1)</strong>
<button type="button" class="btn btn-link" @onclick="() => RemoveBranch(index)">Remove</button>
</div>
<div class="form-grid">
<div class="field-shell">
<label>Branch Kind</label>
<InputText class="input-shell" @bind-Value="branch.BranchKind" />
</div>
<div class="field-shell">
<label>Condition Key</label>
<InputText class="input-shell" @bind-Value="branch.ConditionKey" />
</div>
<div class="field-shell">
<label>Sort Order</label>
<InputNumber TValue="int" class="input-shell" @bind-Value="branch.SortOrder" />
</div>
</div>
<div class="field-shell">
<label>Condition Text</label>
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="branch.ConditionText" />
</div>
<div class="field-shell">
<label>Raw Text</label>
<InputTextArea class="input-shell critical-editor-textarea" @bind-Value="branch.RawText" />
</div>
<div class="field-shell">
<label>Description / Prose</label>
<InputTextArea class="input-shell critical-editor-textarea" @bind-Value="branch.DescriptionText" />
</div>
<div class="field-shell">
<label>Affix Text</label>
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="branch.RawAffixText" />
</div>
<div class="form-grid">
<div class="field-shell">
<label>Condition Json</label>
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="branch.ConditionJson" />
</div>
<div class="field-shell">
<label>Parsed Json</label>
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="branch.ParsedJson" />
</div>
</div>
<div class="critical-editor-subsection">
<div class="critical-editor-section-header">
<h5>Branch Effects</h5>
<button type="button" class="btn-ritual" @onclick="() => AddBranchEffect(branch)">Add Effect</button>
</div>
@if (branch.Effects.Count == 0)
{
<p class="muted">No normalized branch effects.</p>
}
else
{
@for (var effectIndex = 0; effectIndex < branch.Effects.Count; effectIndex++)
{
var effect = branch.Effects[effectIndex];
<div class="critical-editor-card nested">
<div class="critical-editor-card-header">
<strong>Branch Effect @(effectIndex + 1)</strong>
<button type="button" class="btn btn-link" @onclick="() => RemoveBranchEffect(branch, effectIndex)">Remove</button>
</div>
@EffectFields(effect)
</div>
}
}
</div>
</div>
}
}
</section>
</div>
}
<footer class="critical-editor-footer">
<button type="button" class="btn btn-link" @onclick="OnClose" disabled="@IsSaving">Cancel</button>
<button type="button" class="btn-ritual" @onclick="OnSave" disabled="@IsLoading || IsSaving">
@(IsSaving ? "Saving..." : "Save Cell")
</button>
</footer>
</div>
</div>
}
@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) => @<div>
<div class="form-grid">
<div class="field-shell">
<label>Effect Code</label>
<InputText class="input-shell" @bind-Value="effect.EffectCode" />
</div>
<div class="field-shell">
<label>Target</label>
<InputText class="input-shell" @bind-Value="effect.Target" />
</div>
<div class="field-shell">
<label>Source Type</label>
<InputText class="input-shell" @bind-Value="effect.SourceType" />
</div>
<div class="field-shell">
<label>Source Text</label>
<InputText class="input-shell" @bind-Value="effect.SourceText" />
</div>
<div class="field-shell">
<label>Value Integer</label>
<InputNumber TValue="int?" class="input-shell" @bind-Value="effect.ValueInteger" />
</div>
<div class="field-shell">
<label>Value Decimal</label>
<InputNumber TValue="decimal?" class="input-shell" @bind-Value="effect.ValueDecimal" step="0.01" />
</div>
<div class="field-shell">
<label>Value Expression</label>
<InputText class="input-shell" @bind-Value="effect.ValueExpression" />
</div>
<div class="field-shell">
<label>Duration Rounds</label>
<InputNumber TValue="int?" class="input-shell" @bind-Value="effect.DurationRounds" />
</div>
<div class="field-shell">
<label>Per Round</label>
<InputNumber TValue="int?" class="input-shell" @bind-Value="effect.PerRound" />
</div>
<div class="field-shell">
<label>Modifier</label>
<InputNumber TValue="int?" class="input-shell" @bind-Value="effect.Modifier" />
</div>
<div class="field-shell">
<label>Body Part</label>
<InputText class="input-shell" @bind-Value="effect.BodyPart" />
</div>
</div>
<label class="critical-editor-checkbox">
<InputCheckbox class="form-check-input" @bind-Value="effect.IsPermanent" />
<span>Permanent</span>
</label>
</div>;
}

View File

@@ -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<CriticalEffectEditorModel> Effects { get; set; } = [];
public List<CriticalBranchEditorModel> 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());
}

View File

@@ -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);
}

View File

@@ -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<CriticalEffectEditorItem> Effects);

View File

@@ -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<CriticalEffectEditorItem> Effects,
IReadOnlyList<CriticalBranchEditorItem> Branches);

View File

@@ -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<CriticalEffectEditorItem> Effects,
IReadOnlyList<CriticalBranchEditorItem> Branches);

View File

@@ -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);

View File

@@ -96,6 +96,7 @@ public sealed record AttackLookupResponse(
CriticalLookupResponse? AutoCritical);
public sealed record CriticalTableCellDetail(
int ResultId,
string RollBand,
string ColumnKey,
string ColumnLabel,

View File

@@ -231,6 +231,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> 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<RolemasterDbContext> dbConte
cells,
legend);
}
public async Task<CriticalCellEditorResponse?> 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<CriticalCellEditorResponse?> 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<CriticalTableLegendEntry> BuildLegend(IReadOnlyList<CriticalTableCellDetail> cells)
{
var seenCodes = new HashSet<string>(StringComparer.Ordinal);
@@ -320,6 +385,32 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> 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<RolemasterDbContext> 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<CriticalEffect>())
.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<CriticalEffectEditorItem>? effects)
{
dbContext.CriticalEffects.RemoveRange(result.Effects);
result.Effects.Clear();
foreach (var effect in effects ?? Array.Empty<CriticalEffectEditorItem>())
{
result.Effects.Add(CreateEffectEntity(effect));
}
}
private static void ReplaceBranches(
RolemasterDbContext dbContext,
CriticalResult result,
IReadOnlyList<CriticalBranchEditorItem>? 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<CriticalBranchEditorItem>())
{
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<CriticalEffectEditorItem>())
{
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();
}

View File

@@ -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<App>()
.AddInteractiveServerRenderMode();

View File

@@ -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;
}
}