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

@@ -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,9 +117,26 @@
{
foreach (var column in detail.Columns)
{
<td>
@RenderCell(rollBand.Label, group.Key, column.Key)
</td>
@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>
}
}
}
}
@@ -127,9 +144,26 @@
{
foreach (var column in detail.Columns)
{
<td>
@RenderCell(rollBand.Label, null, column.Key)
</td>
@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);
}