Critical tables page
This commit is contained in:
@@ -20,5 +20,11 @@
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> API Surface
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="tables">
|
||||
<span class="bi bi-table" aria-hidden="true"></span> Critical Tables
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
272
src/RolemasterDb.App/Components/Pages/Tables.razor
Normal file
272
src/RolemasterDb.App/Components/Pages/Tables.razor
Normal file
@@ -0,0 +1,272 @@
|
||||
@page "/tables"
|
||||
@using System
|
||||
@using System.Collections.Generic
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@inject LookupService LookupService
|
||||
|
||||
<PageTitle>Critical Tables</PageTitle>
|
||||
|
||||
<section class="hero-panel">
|
||||
<span class="eyebrow">Critical Tables</span>
|
||||
<h1 class="page-title">Browse every imported table</h1>
|
||||
<p class="lede">The authority on this page is the SQLite data that the importer maintains. Switch tables to see the full roll matrix, grouped columns, and affix legend for each import.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel tables-page">
|
||||
<div class="table-selector">
|
||||
<label for="critical-table-select">Critical table</label>
|
||||
<select
|
||||
id="critical-table-select"
|
||||
class="input-shell"
|
||||
value="@selectedTableSlug"
|
||||
@onchange="HandleTableChanged"
|
||||
disabled="@isReferenceDataLoading || (referenceData?.CriticalTables.Count ?? 0) == 0">
|
||||
@if (referenceData is null)
|
||||
{
|
||||
<option value="">Loading tables...</option>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var table in referenceData.CriticalTables)
|
||||
{
|
||||
<option value="@table.Key">@table.Label</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (referenceData is null)
|
||||
{
|
||||
<p class="muted">Loading reference data...</p>
|
||||
}
|
||||
else if (!referenceData.CriticalTables.Any())
|
||||
{
|
||||
<p class="muted">No critical tables have been imported yet.</p>
|
||||
}
|
||||
else if (isDetailLoading)
|
||||
{
|
||||
<p class="muted">Loading the selected table...</p>
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(detailError))
|
||||
{
|
||||
<p class="error-text">@detailError</p>
|
||||
}
|
||||
else if (tableDetail is null)
|
||||
{
|
||||
<p class="muted">The selected table could not be loaded.</p>
|
||||
}
|
||||
else if (tableDetail is { } detail)
|
||||
{
|
||||
<div class="table-shell">
|
||||
<header>
|
||||
<span class="eyebrow">@detail.SourceDocument</span>
|
||||
<h2 class="panel-title">@detail.DisplayName</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(detail.Notes))
|
||||
{
|
||||
<p class="muted">@detail.Notes</p>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table class="critical-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="roll-band-header" rowspan="2">Roll band</th>
|
||||
@if (detail.Groups.Count > 0)
|
||||
{
|
||||
foreach (var group in detail.Groups)
|
||||
{
|
||||
<th colspan="@detail.Columns.Count">@group.Label</th>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<th colspan="@detail.Columns.Count">Columns</th>
|
||||
}
|
||||
</tr>
|
||||
<tr>
|
||||
@if (detail.Groups.Count > 0)
|
||||
{
|
||||
foreach (var group in detail.Groups)
|
||||
{
|
||||
foreach (var column in detail.Columns)
|
||||
{
|
||||
<th>
|
||||
<span>@column.Label</span>
|
||||
<small>@column.Role</small>
|
||||
</th>
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var column in detail.Columns)
|
||||
{
|
||||
<th>
|
||||
<span>@column.Label</span>
|
||||
<small>@column.Role</small>
|
||||
</th>
|
||||
}
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var rollBand in detail.RollBands)
|
||||
{
|
||||
<tr>
|
||||
<th class="roll-band-header">@rollBand.Label</th>
|
||||
@if (detail.Groups.Count > 0)
|
||||
{
|
||||
foreach (var group in detail.Groups)
|
||||
{
|
||||
foreach (var column in detail.Columns)
|
||||
{
|
||||
<td>
|
||||
@RenderCell(rollBand.Label, group.Key, column.Key)
|
||||
</td>
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var column in detail.Columns)
|
||||
{
|
||||
<td>
|
||||
@RenderCell(rollBand.Label, null, column.Key)
|
||||
</td>
|
||||
}
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@{
|
||||
var legendEntries = detail.Legend ?? Array.Empty<CriticalTableLegendEntry>();
|
||||
}
|
||||
|
||||
@if (legendEntries.Count > 0)
|
||||
{
|
||||
<div class="critical-legend">
|
||||
<h4>Affix legend</h4>
|
||||
<div class="legend-grid">
|
||||
@foreach (var entry in legendEntries)
|
||||
{
|
||||
<div class="legend-item" title="@entry.Tooltip">
|
||||
<span class="legend-symbol">@entry.Symbol</span>
|
||||
<div>
|
||||
<strong>@entry.Label</strong>
|
||||
<span class="muted">@entry.Description</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private LookupReferenceData? referenceData;
|
||||
private CriticalTableDetail? tableDetail;
|
||||
private string selectedTableSlug = string.Empty;
|
||||
private bool isDetailLoading;
|
||||
private bool isReferenceDataLoading = true;
|
||||
private string? detailError;
|
||||
private Dictionary<(string RollBand, string? GroupKey, string ColumnKey), CriticalTableCellDetail>? cellIndex;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
referenceData = await LookupService.GetReferenceDataAsync();
|
||||
isReferenceDataLoading = false;
|
||||
|
||||
selectedTableSlug = referenceData?.CriticalTables.FirstOrDefault()?.Key ?? string.Empty;
|
||||
await LoadTableDetailAsync();
|
||||
}
|
||||
|
||||
private async Task HandleTableChanged(ChangeEventArgs args)
|
||||
{
|
||||
selectedTableSlug = args.Value?.ToString() ?? string.Empty;
|
||||
await LoadTableDetailAsync();
|
||||
}
|
||||
|
||||
private async Task LoadTableDetailAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selectedTableSlug))
|
||||
{
|
||||
tableDetail = null;
|
||||
cellIndex = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isDetailLoading = true;
|
||||
detailError = null;
|
||||
tableDetail = null;
|
||||
cellIndex = null;
|
||||
|
||||
try
|
||||
{
|
||||
tableDetail = await LookupService.GetCriticalTableAsync(selectedTableSlug);
|
||||
if (tableDetail is null)
|
||||
{
|
||||
detailError = "The selected table could not be loaded.";
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
detailError = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isDetailLoading = false;
|
||||
BuildCellIndex();
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildCellIndex()
|
||||
{
|
||||
if (tableDetail?.Cells is null)
|
||||
{
|
||||
cellIndex = null;
|
||||
return;
|
||||
}
|
||||
|
||||
cellIndex = new Dictionary<(string, string?, string), CriticalTableCellDetail>();
|
||||
foreach (var cell in tableDetail.Cells)
|
||||
{
|
||||
cellIndex[(cell.RollBand, cell.GroupKey, cell.ColumnKey)] = cell;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
cell = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return cellIndex.TryGetValue((rollBand, groupKey, columnKey), out cell);
|
||||
}
|
||||
}
|
||||
74
src/RolemasterDb.App/Components/Shared/AffixBadgeList.razor
Normal file
74
src/RolemasterDb.App/Components/Shared/AffixBadgeList.razor
Normal file
@@ -0,0 +1,74 @@
|
||||
@using System
|
||||
@using System.Collections.Generic
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@using RolemasterDb.App.Domain
|
||||
@using RolemasterDb.App.Features
|
||||
|
||||
@if (EffectiveEffects.Count > 0)
|
||||
{
|
||||
<div class="affix-badge-list">
|
||||
@foreach (var effect in EffectiveEffects)
|
||||
{
|
||||
if (TryRenderEffect(effect, out var info, out var valueText))
|
||||
{
|
||||
<span class="affix-badge" title="@info.Tooltip">
|
||||
<span class="affix-badge-symbol">@info.Symbol</span>
|
||||
@if (!string.IsNullOrWhiteSpace(valueText))
|
||||
{
|
||||
<span class="affix-badge-value">@valueText</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="affix-badge affix-badge-fallback" title="@effect.EffectCode">
|
||||
@FormatFallback(effect)
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public IReadOnlyList<CriticalEffectLookupResponse>? Effects { get; set; }
|
||||
|
||||
private IReadOnlyList<CriticalEffectLookupResponse> EffectiveEffects =>
|
||||
Effects ?? Array.Empty<CriticalEffectLookupResponse>();
|
||||
|
||||
private static bool TryRenderEffect(
|
||||
CriticalEffectLookupResponse effect,
|
||||
[NotNullWhen(true)] out AffixDisplayInfo? info,
|
||||
out string valueText)
|
||||
{
|
||||
if (!AffixDisplayMap.TryGet(effect.EffectCode, out info))
|
||||
{
|
||||
valueText = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
valueText = FormatAffixValue(effect);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatAffixValue(CriticalEffectLookupResponse effect) =>
|
||||
effect.EffectCode switch
|
||||
{
|
||||
CriticalEffectCodes.StunnedRounds
|
||||
or CriticalEffectCodes.MustParryRounds
|
||||
or CriticalEffectCodes.NoParryRounds => effect.DurationRounds?.ToString() ?? string.Empty,
|
||||
CriticalEffectCodes.BleedPerRound => effect.PerRound?.ToString() ?? string.Empty,
|
||||
CriticalEffectCodes.DirectHits => effect.ValueInteger?.ToString() ?? string.Empty,
|
||||
CriticalEffectCodes.FoePenalty
|
||||
or CriticalEffectCodes.AttackerBonusNextRound => effect.Modifier?.ToString() ?? string.Empty,
|
||||
CriticalEffectCodes.PowerPointModifier => effect.ValueExpression ?? string.Empty,
|
||||
_ => effect.ValueInteger?.ToString()
|
||||
?? effect.Modifier?.ToString()
|
||||
?? effect.ValueExpression
|
||||
?? effect.SourceText
|
||||
?? string.Empty
|
||||
};
|
||||
|
||||
private static string FormatFallback(CriticalEffectLookupResponse effect) =>
|
||||
effect.SourceText ?? effect.EffectCode;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
@using System.Collections.Generic
|
||||
@using RolemasterDb.App.Features
|
||||
|
||||
<div class="critical-cell">
|
||||
@if (!string.IsNullOrWhiteSpace(Description))
|
||||
{
|
||||
<p class="critical-cell-description">@Description</p>
|
||||
}
|
||||
|
||||
<AffixBadgeList Effects="Effects" />
|
||||
|
||||
@if (Branches?.Count > 0)
|
||||
{
|
||||
<div class="critical-branch-stack">
|
||||
@foreach (var branch in Branches)
|
||||
{
|
||||
<div class="critical-branch-card">
|
||||
<span class="critical-branch-condition">
|
||||
@branch.ConditionText
|
||||
</span>
|
||||
@if (!string.IsNullOrWhiteSpace(branch.Description))
|
||||
{
|
||||
<p class="critical-branch-description">@branch.Description</p>
|
||||
}
|
||||
<AffixBadgeList Effects="branch.Effects" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<CriticalEffectLookupResponse>? Effects { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<CriticalBranchLookupResponse>? Branches { get; set; }
|
||||
}
|
||||
@@ -42,93 +42,13 @@
|
||||
<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>
|
||||
|
||||
@if (Result.Effects.Count > 0)
|
||||
{
|
||||
<div class="effect-stack">
|
||||
<h5>Parsed Affixes</h5>
|
||||
<ul class="effect-list">
|
||||
@foreach (var effect in Result.Effects)
|
||||
{
|
||||
<li class="effect-item">
|
||||
@if (!string.IsNullOrWhiteSpace(effect.SourceText))
|
||||
{
|
||||
<code class="effect-token">@effect.SourceText</code>
|
||||
}
|
||||
<span>@FormatEffect(effect)</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (Result.Effects.Count > 0)
|
||||
{
|
||||
<div class="callout">
|
||||
<h4>Parsed Affixes</h4>
|
||||
<ul class="effect-list">
|
||||
@foreach (var effect in Result.Effects)
|
||||
{
|
||||
<li class="effect-item">
|
||||
@if (!string.IsNullOrWhiteSpace(effect.SourceText))
|
||||
{
|
||||
<code class="effect-token">@effect.SourceText</code>
|
||||
}
|
||||
<span>@FormatEffect(effect)</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Result.Branches.Count > 0)
|
||||
{
|
||||
<div class="callout">
|
||||
<h4>Conditional Branches</h4>
|
||||
<div class="branch-list">
|
||||
@foreach (var branch in Result.Branches)
|
||||
{
|
||||
<section class="branch-card">
|
||||
<div class="branch-condition">@branch.ConditionText</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(branch.Description))
|
||||
{
|
||||
<p class="branch-copy">@branch.Description</p>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(branch.AffixText))
|
||||
{
|
||||
<p class="stacked-copy branch-affix">@branch.AffixText</p>
|
||||
}
|
||||
|
||||
@if (branch.Effects.Count > 0)
|
||||
{
|
||||
<ul class="effect-list branch-effects">
|
||||
@foreach (var effect in branch.Effects)
|
||||
{
|
||||
<li class="effect-item">
|
||||
@if (!string.IsNullOrWhiteSpace(effect.SourceText))
|
||||
{
|
||||
<code class="effect-token">@effect.SourceText</code>
|
||||
}
|
||||
<span>@FormatEffect(effect)</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="callout">
|
||||
<h4>Cell details</h4>
|
||||
<CompactCriticalCell
|
||||
Description="@Result.Description"
|
||||
Effects="@Result.Effects"
|
||||
Branches="@Result.Branches" />
|
||||
</div>
|
||||
|
||||
<details class="details-block">
|
||||
<summary>Raw Imported Cell</summary>
|
||||
@@ -170,20 +90,4 @@
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatEffect(CriticalEffectLookupResponse effect) =>
|
||||
effect.EffectCode switch
|
||||
{
|
||||
"direct_hits" when effect.ValueInteger is not null => $"{effect.ValueInteger} direct hits",
|
||||
"must_parry_rounds" when effect.DurationRounds is not null => $"Must parry for {FormatRounds(effect.DurationRounds.Value)}",
|
||||
"no_parry_rounds" when effect.DurationRounds is not null => $"No parry for {FormatRounds(effect.DurationRounds.Value)}",
|
||||
"stunned_rounds" when effect.DurationRounds is not null => $"Stunned for {FormatRounds(effect.DurationRounds.Value)}",
|
||||
"bleed_per_round" when effect.PerRound is not null => $"Bleeds {effect.PerRound} hits per round",
|
||||
"foe_penalty" when effect.Modifier is not null => $"Foe penalty {effect.Modifier:+#;-#;0}",
|
||||
"attacker_bonus_next_round" when effect.Modifier is not null => $"Attacker bonus next round {effect.Modifier:+#;-#;0}",
|
||||
"power_point_modifier" when !string.IsNullOrWhiteSpace(effect.ValueExpression) => $"Foe power-point modifier {effect.ValueExpression}",
|
||||
_ => effect.EffectCode.Replace('_', ' ')
|
||||
};
|
||||
|
||||
private static string FormatRounds(int value) =>
|
||||
value == 1 ? "1 round" : $"{value} rounds";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user