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

View File

@@ -17,8 +17,8 @@ else
<span class="eyebrow">Rolemaster Lookup Desk</span> <span class="eyebrow">Rolemaster Lookup Desk</span>
<h1 class="page-title">Resolve the attack roll, then the critical, from one place.</h1> <h1 class="page-title">Resolve the attack roll, then the critical, from one place.</h1>
<p class="lede"> <p class="lede">
This starter app seeds a small SQLite dataset and exposes the same lookup flow through Blazor and minimal APIs. Attack tables still come from the starter dataset, while critical lookups now read the importer-managed tables loaded into the same SQLite file.
The current data is intentionally limited to a first pass so the import pipeline can grow from a working base. The page surfaces both the gameplay result and the import metadata behind each critical entry.
</p> </p>
<div class="tag-row"> <div class="tag-row">
<span class="tag">@referenceData.AttackTables.Count attack tables</span> <span class="tag">@referenceData.AttackTables.Count attack tables</span>
@@ -104,16 +104,7 @@ else
{ {
<div class="callout"> <div class="callout">
<h4>Automatic critical resolution</h4> <h4>Automatic critical resolution</h4>
<div class="result-stats"> <CriticalLookupResultCard Result="attackResult.AutoCritical" />
<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>
}
</div> </div>
} }
else if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity)) else if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity))
@@ -127,7 +118,7 @@ else
<section class="panel"> <section class="panel">
<h2 class="panel-title">Direct Critical Lookup</h2> <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="lookup-form">
<div class="form-grid"> <div class="form-grid">
@@ -146,11 +137,24 @@ else
<select id="critical-column" class="input-shell" @bind="criticalInput.Column"> <select id="critical-column" class="input-shell" @bind="criticalInput.Column">
@foreach (var column in SelectedCriticalTable?.Columns ?? []) @foreach (var column in SelectedCriticalTable?.Columns ?? [])
{ {
<option value="@column.Key">@column.Label</option> <option value="@column.Key">@column.Label (@column.Role)</option>
} }
</select> </select>
</div> </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"> <div class="field-shell">
<label for="critical-roll-direct">Critical roll</label> <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" /> <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) @if (criticalResult is not null)
{ {
<div class="result-shell"> <div class="result-shell">
<div class="result-card"> <CriticalLookupResultCard Result="criticalResult" />
<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>
</div> </div>
} }
</section> </section>
<section class="panel"> <section class="panel">
<h2 class="panel-title">Seeded Reference Data</h2> <h2 class="panel-title">Loaded 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> <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"> <div class="table-list">
@foreach (var attackTable in referenceData.AttackTables) @foreach (var attackTable in referenceData.AttackTables)
@@ -202,10 +194,7 @@ else
@foreach (var criticalTable in referenceData.CriticalTables) @foreach (var criticalTable in referenceData.CriticalTables)
{ {
<div class="table-list-item"> <CriticalTableReferenceCard Table="criticalTable" />
<strong>@criticalTable.Label</strong>
<span class="muted">Critical key: <code>@criticalTable.Key</code>, columns: @string.Join(", ", criticalTable.Columns.Select(column => column.Label))</span>
</div>
} }
</div> </div>
</section> </section>
@@ -234,6 +223,7 @@ else
var initialCriticalTable = referenceData.CriticalTables.FirstOrDefault(); var initialCriticalTable = referenceData.CriticalTables.FirstOrDefault();
criticalInput.CriticalType = initialCriticalTable?.Key ?? string.Empty; criticalInput.CriticalType = initialCriticalTable?.Key ?? string.Empty;
criticalInput.Column = initialCriticalTable?.Columns.FirstOrDefault()?.Key ?? string.Empty; criticalInput.Column = initialCriticalTable?.Columns.FirstOrDefault()?.Key ?? string.Empty;
criticalInput.Group = initialCriticalTable?.Groups.FirstOrDefault()?.Key ?? string.Empty;
} }
private async Task RunAttackLookupAsync() private async Task RunAttackLookupAsync()
@@ -271,11 +261,11 @@ else
criticalInput.CriticalType, criticalInput.CriticalType,
criticalInput.Column, criticalInput.Column,
criticalInput.Roll, criticalInput.Roll,
null)); SelectedCriticalTable?.Groups.Count > 0 ? criticalInput.Group : null));
if (response is 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; return;
} }
@@ -288,6 +278,7 @@ else
var table = referenceData?.CriticalTables.FirstOrDefault(item => item.Key == criticalInput.CriticalType); var table = referenceData?.CriticalTables.FirstOrDefault(item => item.Key == criticalInput.CriticalType);
criticalInput.Column = table?.Columns.FirstOrDefault()?.Key ?? string.Empty; criticalInput.Column = table?.Columns.FirstOrDefault()?.Key ?? string.Empty;
criticalInput.Group = table?.Groups.FirstOrDefault()?.Key ?? string.Empty;
criticalResult = null; criticalResult = null;
criticalError = null; criticalError = null;
} }
@@ -304,6 +295,7 @@ else
{ {
public string CriticalType { get; set; } = string.Empty; public string CriticalType { get; set; } = string.Empty;
public string Column { get; set; } = string.Empty; public string Column { get; set; } = string.Empty;
public string Group { get; set; } = string.Empty;
public int Roll { get; set; } = 72; 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
@using RolemasterDb.App.Components @using RolemasterDb.App.Components
@using RolemasterDb.App.Components.Layout @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 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( public sealed record CriticalTableReference(
string Key, string Key,
string Label, string Label,
IReadOnlyList<LookupOption> Columns, string Family,
IReadOnlyList<LookupOption> Groups); string SourceDocument,
string? Notes,
IReadOnlyList<CriticalColumnReference> Columns,
IReadOnlyList<CriticalGroupReference> Groups,
IReadOnlyList<CriticalRollBandReference> RollBands);
public sealed record LookupReferenceData( public sealed record LookupReferenceData(
IReadOnlyList<LookupOption> AttackTables, IReadOnlyList<LookupOption> AttackTables,
@@ -28,12 +49,23 @@ public sealed record CriticalLookupRequest(
public sealed record CriticalLookupResponse( public sealed record CriticalLookupResponse(
string CriticalType, string CriticalType,
string CriticalTableName, string CriticalTableName,
string CriticalFamily,
string SourceDocument,
string? TableNotes,
string? Group, string? Group,
string? GroupLabel,
string Column, string Column,
string ColumnLabel,
string ColumnRole,
int Roll, int Roll,
string RollBand, string RollBand,
int RollBandMinRoll,
int? RollBandMaxRoll,
string RawCellText,
string Description, string Description,
string? AffixText); string? AffixText,
string ParseStatus,
string ParsedJson);
public sealed record AttackLookupResponse( public sealed record AttackLookupResponse(
string AttackTable, string AttackTable,

View File

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

Binary file not shown.

View File

@@ -182,6 +182,29 @@ textarea {
margin-bottom: 0.45rem; 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 { .result-stats {
display: flex; display: flex;
gap: 0.7rem; gap: 0.7rem;
@@ -224,6 +247,43 @@ textarea {
.table-list-item strong { .table-list-item strong {
display: block; 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 { .code-block {