Expose imported critical data in web app

This commit is contained in:
2026-03-14 02:40:57 +01:00
parent c7467aad13
commit 73ce64e879
9 changed files with 331 additions and 44 deletions

View File

@@ -15,6 +15,15 @@
<pre class="code-block">{
"attackTables": [
{ "key": "broadsword", "label": "Broadsword" }
],
"criticalTables": [
{
"key": "mana",
"label": "Mana Critical Strike Table",
"family": "standard",
"sourceDocument": "Mana.pdf",
"notes": "Imported from PDF XML extraction."
}
]
}</pre>
</section>
@@ -34,10 +43,11 @@
<h2 class="panel-title">Critical lookup</h2>
<p class="panel-copy"><code>POST /api/lookup/critical</code></p>
<pre class="code-block">{
"criticalType": "slash",
"column": "B",
"roll": 72,
"criticalType": "mana",
"column": "E",
"roll": 100,
"group": null
}</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>
</div>

View File

@@ -17,8 +17,8 @@ else
<span class="eyebrow">Rolemaster Lookup Desk</span>
<h1 class="page-title">Resolve the attack roll, then the critical, from one place.</h1>
<p class="lede">
This starter app seeds a small SQLite dataset and exposes the same lookup flow through Blazor and minimal APIs.
The current data is intentionally limited to a first pass so the import pipeline can grow from a working base.
Attack tables still come from the starter dataset, while critical lookups now read the importer-managed tables loaded into the same SQLite file.
The page surfaces both the gameplay result and the import metadata behind each critical entry.
</p>
<div class="tag-row">
<span class="tag">@referenceData.AttackTables.Count attack tables</span>
@@ -104,16 +104,7 @@ else
{
<div class="callout">
<h4>Automatic critical resolution</h4>
<div class="result-stats">
<span class="stat-pill">@attackResult.AutoCritical.CriticalTableName</span>
<span class="stat-pill">Column: @attackResult.AutoCritical.Column</span>
<span class="stat-pill">Band: @attackResult.AutoCritical.RollBand</span>
</div>
<p><strong>@attackResult.AutoCritical.Description</strong></p>
@if (!string.IsNullOrWhiteSpace(attackResult.AutoCritical.AffixText))
{
<p class="muted">@attackResult.AutoCritical.AffixText</p>
}
<CriticalLookupResultCard Result="attackResult.AutoCritical" />
</div>
}
else if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity))
@@ -127,7 +118,7 @@ else
<section class="panel">
<h2 class="panel-title">Direct Critical Lookup</h2>
<p class="panel-copy">Use this when you already know the critical table, column, and roll.</p>
<p class="panel-copy">Use this when you already know the critical table, column, roll, and group if the selected table has variants.</p>
<div class="lookup-form">
<div class="form-grid">
@@ -146,11 +137,24 @@ else
<select id="critical-column" class="input-shell" @bind="criticalInput.Column">
@foreach (var column in SelectedCriticalTable?.Columns ?? [])
{
<option value="@column.Key">@column.Label</option>
<option value="@column.Key">@column.Label (@column.Role)</option>
}
</select>
</div>
@if (SelectedCriticalTable?.Groups.Count > 0)
{
<div class="field-shell">
<label for="critical-group">Group</label>
<select id="critical-group" class="input-shell" @bind="criticalInput.Group">
@foreach (var group in SelectedCriticalTable.Groups)
{
<option value="@group.Key">@group.Label</option>
}
</select>
</div>
}
<div class="field-shell">
<label for="critical-roll-direct">Critical roll</label>
<input id="critical-roll-direct" class="input-shell" type="number" min="1" max="100" @bind="criticalInput.Roll" />
@@ -170,26 +174,14 @@ else
@if (criticalResult is not null)
{
<div class="result-shell">
<div class="result-card">
<h3>@criticalResult.CriticalTableName</h3>
<div class="result-stats">
<span class="stat-pill">Column: @criticalResult.Column</span>
<span class="stat-pill">Band: @criticalResult.RollBand</span>
<span class="stat-pill">Roll: @criticalResult.Roll</span>
</div>
<p><strong>@criticalResult.Description</strong></p>
@if (!string.IsNullOrWhiteSpace(criticalResult.AffixText))
{
<p class="muted">@criticalResult.AffixText</p>
}
</div>
<CriticalLookupResultCard Result="criticalResult" />
</div>
}
</section>
<section class="panel">
<h2 class="panel-title">Seeded Reference Data</h2>
<p class="panel-copy">The schema supports much more than what is seeded today. These are the initial tables standing up the flow.</p>
<h2 class="panel-title">Loaded Reference Data</h2>
<p class="panel-copy">Attack tables remain starter content. Critical tables below are whatever importer-managed entries are currently loaded into the app database.</p>
<div class="table-list">
@foreach (var attackTable in referenceData.AttackTables)
@@ -202,10 +194,7 @@ else
@foreach (var criticalTable in referenceData.CriticalTables)
{
<div class="table-list-item">
<strong>@criticalTable.Label</strong>
<span class="muted">Critical key: <code>@criticalTable.Key</code>, columns: @string.Join(", ", criticalTable.Columns.Select(column => column.Label))</span>
</div>
<CriticalTableReferenceCard Table="criticalTable" />
}
</div>
</section>
@@ -234,6 +223,7 @@ else
var initialCriticalTable = referenceData.CriticalTables.FirstOrDefault();
criticalInput.CriticalType = initialCriticalTable?.Key ?? string.Empty;
criticalInput.Column = initialCriticalTable?.Columns.FirstOrDefault()?.Key ?? string.Empty;
criticalInput.Group = initialCriticalTable?.Groups.FirstOrDefault()?.Key ?? string.Empty;
}
private async Task RunAttackLookupAsync()
@@ -271,11 +261,11 @@ else
criticalInput.CriticalType,
criticalInput.Column,
criticalInput.Roll,
null));
SelectedCriticalTable?.Groups.Count > 0 ? criticalInput.Group : null));
if (response is null)
{
criticalError = "No seeded critical result matched that table, column, and roll.";
criticalError = "No loaded critical result matched that table, group, column, and roll.";
return;
}
@@ -288,6 +278,7 @@ else
var table = referenceData?.CriticalTables.FirstOrDefault(item => item.Key == criticalInput.CriticalType);
criticalInput.Column = table?.Columns.FirstOrDefault()?.Key ?? string.Empty;
criticalInput.Group = table?.Groups.FirstOrDefault()?.Key ?? string.Empty;
criticalResult = null;
criticalError = null;
}
@@ -304,6 +295,7 @@ else
{
public string CriticalType { get; set; } = string.Empty;
public string Column { get; set; } = string.Empty;
public string Group { get; set; } = string.Empty;
public int Roll { get; set; } = 72;
}
}

View File

@@ -0,0 +1,94 @@
@using System.Text.Json
<div class="result-card">
<h3>@Result.CriticalTableName</h3>
<div class="result-stats">
<span class="stat-pill">Table: <code>@Result.CriticalType</code></span>
<span class="stat-pill">Column: @Result.ColumnLabel</span>
<span class="stat-pill">Role: @Result.ColumnRole</span>
<span class="stat-pill">Band: @Result.RollBand</span>
<span class="stat-pill">Roll: @Result.Roll</span>
<span class="stat-pill">Status: @Result.ParseStatus</span>
</div>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Family</span>
<span>@Result.CriticalFamily</span>
</div>
<div class="detail-item">
<span class="detail-label">Source</span>
<span>@Result.SourceDocument</span>
</div>
<div class="detail-item">
<span class="detail-label">Column Key</span>
<span><code>@Result.Column</code></span>
</div>
<div class="detail-item">
<span class="detail-label">Roll Range</span>
<span>@FormatRollRange(Result.RollBandMinRoll, Result.RollBandMaxRoll)</span>
</div>
@if (!string.IsNullOrWhiteSpace(Result.Group))
{
<div class="detail-item">
<span class="detail-label">Group</span>
<span>@(string.IsNullOrWhiteSpace(Result.GroupLabel) ? Result.Group : $"{Result.GroupLabel} ({Result.Group})")</span>
</div>
}
</div>
@if (!string.IsNullOrWhiteSpace(Result.TableNotes))
{
<p class="muted">@Result.TableNotes</p>
}
<p><strong>@Result.Description</strong></p>
@if (!string.IsNullOrWhiteSpace(Result.AffixText))
{
<div class="callout">
<h4>Affix Text</h4>
<p class="stacked-copy">@Result.AffixText</p>
</div>
}
<details class="details-block">
<summary>Raw Imported Cell</summary>
<pre class="code-block">@Result.RawCellText</pre>
</details>
<details class="details-block">
<summary>Parsed JSON</summary>
<pre class="code-block">@FormatJson(Result.ParsedJson)</pre>
</details>
</div>
@code {
[Parameter, EditorRequired]
public CriticalLookupResponse Result { get; set; } = null!;
private static string FormatRollRange(int minRoll, int? maxRoll) =>
maxRoll is null
? $"{minRoll}+"
: minRoll == maxRoll
? minRoll.ToString()
: $"{minRoll}-{maxRoll}";
private static string FormatJson(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "{}";
}
try
{
using var document = JsonDocument.Parse(value);
return JsonSerializer.Serialize(document.RootElement, new JsonSerializerOptions { WriteIndented = true });
}
catch (JsonException)
{
return value;
}
}
}

View File

@@ -0,0 +1,73 @@
<div class="table-list-item">
<strong>@Table.Label</strong>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Key</span>
<span><code>@Table.Key</code></span>
</div>
<div class="detail-item">
<span class="detail-label">Family</span>
<span>@Table.Family</span>
</div>
<div class="detail-item">
<span class="detail-label">Source</span>
<span>@Table.SourceDocument</span>
</div>
<div class="detail-item">
<span class="detail-label">Columns</span>
<span>@Table.Columns.Count</span>
</div>
<div class="detail-item">
<span class="detail-label">Groups</span>
<span>@Table.Groups.Count</span>
</div>
<div class="detail-item">
<span class="detail-label">Roll Bands</span>
<span>@Table.RollBands.Count</span>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(Table.Notes))
{
<p class="muted">@Table.Notes</p>
}
<div class="chip-row">
@foreach (var column in Table.Columns)
{
<span class="chip">@column.Label <small>(@column.Key, @column.Role, #@column.SortOrder)</small></span>
}
</div>
@if (Table.Groups.Count > 0)
{
<div class="chip-row">
@foreach (var group in Table.Groups)
{
<span class="chip">@group.Label <small>(@group.Key, #@group.SortOrder)</small></span>
}
</div>
}
<details class="details-block">
<summary>Roll Bands (@Table.RollBands.Count)</summary>
<div class="chip-row">
@foreach (var rollBand in Table.RollBands)
{
<span class="chip">@rollBand.Label <small>(@FormatRollRange(rollBand.MinRoll, rollBand.MaxRoll), #@rollBand.SortOrder)</small></span>
}
</div>
</details>
</div>
@code {
[Parameter, EditorRequired]
public CriticalTableReference Table { get; set; } = null!;
private static string FormatRollRange(int minRoll, int? maxRoll) =>
maxRoll is null
? $"{minRoll}+"
: minRoll == maxRoll
? minRoll.ToString()
: $"{minRoll}-{maxRoll}";
}

View File

@@ -11,3 +11,4 @@
@using RolemasterDb.App
@using RolemasterDb.App.Components
@using RolemasterDb.App.Components.Layout
@using RolemasterDb.App.Components.Shared

View File

@@ -2,11 +2,32 @@ namespace RolemasterDb.App.Features;
public sealed record LookupOption(string Key, string Label);
public sealed record CriticalColumnReference(
string Key,
string Label,
string Role,
int SortOrder);
public sealed record CriticalGroupReference(
string Key,
string Label,
int SortOrder);
public sealed record CriticalRollBandReference(
string Label,
int MinRoll,
int? MaxRoll,
int SortOrder);
public sealed record CriticalTableReference(
string Key,
string Label,
IReadOnlyList<LookupOption> Columns,
IReadOnlyList<LookupOption> Groups);
string Family,
string SourceDocument,
string? Notes,
IReadOnlyList<CriticalColumnReference> Columns,
IReadOnlyList<CriticalGroupReference> Groups,
IReadOnlyList<CriticalRollBandReference> RollBands);
public sealed record LookupReferenceData(
IReadOnlyList<LookupOption> AttackTables,
@@ -28,12 +49,23 @@ public sealed record CriticalLookupRequest(
public sealed record CriticalLookupResponse(
string CriticalType,
string CriticalTableName,
string CriticalFamily,
string SourceDocument,
string? TableNotes,
string? Group,
string? GroupLabel,
string Column,
string ColumnLabel,
string ColumnRole,
int Roll,
string RollBand,
int RollBandMinRoll,
int? RollBandMaxRoll,
string RawCellText,
string Description,
string? AffixText);
string? AffixText,
string ParseStatus,
string ParsedJson);
public sealed record AttackLookupResponse(
string AttackTable,

View File

@@ -26,6 +26,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
.AsSplitQuery()
.Include(item => item.Columns)
.Include(item => item.Groups)
.Include(item => item.RollBands)
.OrderBy(item => item.DisplayName)
.ToListAsync(cancellationToken);
@@ -35,8 +36,21 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
criticalTables.Select(item => new CriticalTableReference(
item.Slug,
item.DisplayName,
item.Columns.OrderBy(column => column.SortOrder).Select(column => new LookupOption(column.ColumnKey, column.Label)).ToList(),
item.Groups.OrderBy(group => group.SortOrder).Select(group => new LookupOption(group.GroupKey, group.Label)).ToList()))
item.Family,
item.SourceDocument,
item.Notes,
item.Columns
.OrderBy(column => column.SortOrder)
.Select(column => new CriticalColumnReference(column.ColumnKey, column.Label, column.Role, column.SortOrder))
.ToList(),
item.Groups
.OrderBy(group => group.SortOrder)
.Select(group => new CriticalGroupReference(group.GroupKey, group.Label, group.SortOrder))
.ToList(),
item.RollBands
.OrderBy(rollBand => rollBand.SortOrder)
.Select(rollBand => new CriticalRollBandReference(rollBand.Label, rollBand.MinRoll, rollBand.MaxRoll, rollBand.SortOrder))
.ToList()))
.ToList());
}
@@ -102,12 +116,23 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
.Select(item => new CriticalLookupResponse(
item.CriticalTable.Slug,
item.CriticalTable.DisplayName,
item.CriticalTable.Family,
item.CriticalTable.SourceDocument,
item.CriticalTable.Notes,
item.CriticalGroup != null ? item.CriticalGroup.GroupKey : null,
item.CriticalGroup != null ? item.CriticalGroup.Label : null,
item.CriticalColumn.ColumnKey,
item.CriticalColumn.Label,
item.CriticalColumn.Role,
request.Roll,
item.CriticalRollBand.Label,
item.CriticalRollBand.MinRoll,
item.CriticalRollBand.MaxRoll,
item.RawCellText,
item.DescriptionText,
item.RawAffixText))
item.RawAffixText,
item.ParseStatus,
item.ParsedJson))
.SingleOrDefaultAsync(cancellationToken);
}

Binary file not shown.

View File

@@ -182,6 +182,29 @@ textarea {
margin-bottom: 0.45rem;
}
.detail-grid {
display: grid;
gap: 0.7rem;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
margin-bottom: 0.9rem;
}
.detail-item {
display: grid;
gap: 0.15rem;
padding: 0.65rem 0.75rem;
border-radius: 14px;
background: rgba(255, 250, 240, 0.72);
border: 1px solid rgba(127, 96, 55, 0.12);
}
.detail-label {
color: #75562f;
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.result-stats {
display: flex;
gap: 0.7rem;
@@ -224,6 +247,43 @@ textarea {
.table-list-item strong {
display: block;
margin-bottom: 0.6rem;
}
.chip-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.8rem;
}
.chip {
border-radius: 999px;
padding: 0.38rem 0.68rem;
background: rgba(238, 223, 193, 0.5);
border: 1px solid rgba(127, 96, 55, 0.14);
color: #5b4327;
font-size: 0.82rem;
}
.chip small {
color: var(--ink-soft);
}
.details-block {
margin-top: 0.85rem;
}
.details-block summary {
cursor: pointer;
color: var(--accent);
font-weight: 600;
margin-bottom: 0.6rem;
}
.stacked-copy {
margin: 0;
white-space: pre-wrap;
}
.code-block {