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
|
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> API Surface
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</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>
|
</nav>
|
||||||
</div>
|
</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 class="muted">@Result.TableNotes</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<p><strong>@Result.Description</strong></p>
|
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(Result.AffixText))
|
|
||||||
{
|
|
||||||
<div class="callout">
|
<div class="callout">
|
||||||
<h4>Affix Text</h4>
|
<h4>Cell details</h4>
|
||||||
<p class="stacked-copy">@Result.AffixText</p>
|
<CompactCriticalCell
|
||||||
|
Description="@Result.Description"
|
||||||
@if (Result.Effects.Count > 0)
|
Effects="@Result.Effects"
|
||||||
{
|
Branches="@Result.Branches" />
|
||||||
<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>
|
||||||
}
|
|
||||||
</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>
|
|
||||||
}
|
|
||||||
|
|
||||||
<details class="details-block">
|
<details class="details-block">
|
||||||
<summary>Raw Imported Cell</summary>
|
<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";
|
|
||||||
}
|
}
|
||||||
|
|||||||
7
src/RolemasterDb.App/Domain/AffixDisplayInfo.cs
Normal file
7
src/RolemasterDb.App/Domain/AffixDisplayInfo.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace RolemasterDb.App.Domain;
|
||||||
|
|
||||||
|
public sealed record AffixDisplayInfo(
|
||||||
|
string Label,
|
||||||
|
string Symbol,
|
||||||
|
string Description,
|
||||||
|
string Tooltip);
|
||||||
57
src/RolemasterDb.App/Domain/AffixDisplayMap.cs
Normal file
57
src/RolemasterDb.App/Domain/AffixDisplayMap.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace RolemasterDb.App.Domain;
|
||||||
|
|
||||||
|
public static class AffixDisplayMap
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, AffixDisplayInfo> Map = new Dictionary<string, AffixDisplayInfo>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
[CriticalEffectCodes.StunnedRounds] = new(
|
||||||
|
"Stunned",
|
||||||
|
"💫",
|
||||||
|
"Cannot act for the indicated rounds.",
|
||||||
|
"Stunned for the displayed duration."),
|
||||||
|
[CriticalEffectCodes.MustParryRounds] = new(
|
||||||
|
"Must Parry",
|
||||||
|
"🛡️",
|
||||||
|
"Forced parry for the indicated rounds.",
|
||||||
|
"Must parry until the duration ends."),
|
||||||
|
[CriticalEffectCodes.NoParryRounds] = new(
|
||||||
|
"No Parry",
|
||||||
|
"🚫🛡️",
|
||||||
|
"Cannot parry for the indicated rounds.",
|
||||||
|
"No parry for the displayed duration."),
|
||||||
|
[CriticalEffectCodes.BleedPerRound] = new(
|
||||||
|
"Bleed",
|
||||||
|
"🩸",
|
||||||
|
"Bleeds the displayed hits each round.",
|
||||||
|
"Bleeds the shown hits per round."),
|
||||||
|
[CriticalEffectCodes.DirectHits] = new(
|
||||||
|
"Direct Hits",
|
||||||
|
"🗡️",
|
||||||
|
"Additional direct hits.",
|
||||||
|
"Direct hits equal to the listed value."),
|
||||||
|
[CriticalEffectCodes.FoePenalty] = new(
|
||||||
|
"Foe Penalty",
|
||||||
|
"🔻",
|
||||||
|
"Penalty applied to the foe.",
|
||||||
|
"Foe penalty of the displayed value."),
|
||||||
|
[CriticalEffectCodes.AttackerBonusNextRound] = new(
|
||||||
|
"Attacker Bonus",
|
||||||
|
"✨",
|
||||||
|
"Attacker bonus next round.",
|
||||||
|
"Adds the shown bonus next round."),
|
||||||
|
[CriticalEffectCodes.PowerPointModifier] = new(
|
||||||
|
"Power Point Mod",
|
||||||
|
"⚡",
|
||||||
|
"Adjusts the foe's power points.",
|
||||||
|
"Power-point modifier per the expression.")
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool TryGet(string effectCode, [NotNullWhen(true)] out AffixDisplayInfo? info) =>
|
||||||
|
Map.TryGetValue(effectCode, out info);
|
||||||
|
|
||||||
|
public static IReadOnlyDictionary<string, AffixDisplayInfo> Entries => Map;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace RolemasterDb.App.Features;
|
namespace RolemasterDb.App.Features;
|
||||||
|
|
||||||
public sealed record LookupOption(string Key, string Label);
|
public sealed record LookupOption(string Key, string Label);
|
||||||
@@ -92,3 +94,33 @@ public sealed record AttackLookupResponse(
|
|||||||
string RawNotation,
|
string RawNotation,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
CriticalLookupResponse? AutoCritical);
|
CriticalLookupResponse? AutoCritical);
|
||||||
|
|
||||||
|
public sealed record CriticalTableCellDetail(
|
||||||
|
string RollBand,
|
||||||
|
string ColumnKey,
|
||||||
|
string ColumnLabel,
|
||||||
|
string ColumnRole,
|
||||||
|
string? GroupKey,
|
||||||
|
string? GroupLabel,
|
||||||
|
string? Description,
|
||||||
|
IReadOnlyList<CriticalEffectLookupResponse> Effects,
|
||||||
|
IReadOnlyList<CriticalBranchLookupResponse> Branches);
|
||||||
|
|
||||||
|
public sealed record CriticalTableLegendEntry(
|
||||||
|
string EffectCode,
|
||||||
|
string Symbol,
|
||||||
|
string Label,
|
||||||
|
string Description,
|
||||||
|
string Tooltip);
|
||||||
|
|
||||||
|
public sealed record CriticalTableDetail(
|
||||||
|
string Slug,
|
||||||
|
string DisplayName,
|
||||||
|
string Family,
|
||||||
|
string SourceDocument,
|
||||||
|
string? Notes,
|
||||||
|
IReadOnlyList<CriticalColumnReference> Columns,
|
||||||
|
IReadOnlyList<CriticalGroupReference> Groups,
|
||||||
|
IReadOnlyList<CriticalRollBandReference> RollBands,
|
||||||
|
IReadOnlyList<CriticalTableCellDetail> Cells,
|
||||||
|
IReadOnlyList<CriticalTableLegendEntry> Legend);
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using RolemasterDb.App.Data;
|
using RolemasterDb.App.Data;
|
||||||
|
using RolemasterDb.App.Domain;
|
||||||
|
|
||||||
namespace RolemasterDb.App.Features;
|
namespace RolemasterDb.App.Features;
|
||||||
|
|
||||||
@@ -178,6 +182,158 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
|||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<CriticalTableDetail?> GetCriticalTableAsync(string slug, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var table = await dbContext.CriticalTables
|
||||||
|
.AsNoTracking()
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(item => item.Columns)
|
||||||
|
.Include(item => item.Groups)
|
||||||
|
.Include(item => item.RollBands)
|
||||||
|
.Include(item => item.Results)
|
||||||
|
.ThenInclude(result => result.CriticalColumn)
|
||||||
|
.Include(item => item.Results)
|
||||||
|
.ThenInclude(result => result.CriticalGroup)
|
||||||
|
.Include(item => item.Results)
|
||||||
|
.ThenInclude(result => result.CriticalRollBand)
|
||||||
|
.Include(item => item.Results)
|
||||||
|
.ThenInclude(result => result.Effects)
|
||||||
|
.Include(item => item.Results)
|
||||||
|
.ThenInclude(result => result.Branches)
|
||||||
|
.ThenInclude(branch => branch.Effects)
|
||||||
|
.Where(item => item.Slug == slug)
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (table is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var columns = table.Columns
|
||||||
|
.OrderBy(column => column.SortOrder)
|
||||||
|
.Select(column => new CriticalColumnReference(column.ColumnKey, column.Label, column.Role, column.SortOrder))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var groups = table.Groups
|
||||||
|
.OrderBy(group => group.SortOrder)
|
||||||
|
.Select(group => new CriticalGroupReference(group.GroupKey, group.Label, group.SortOrder))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var rollBands = table.RollBands
|
||||||
|
.OrderBy(rollBand => rollBand.SortOrder)
|
||||||
|
.Select(rollBand => new CriticalRollBandReference(rollBand.Label, rollBand.MinRoll, rollBand.MaxRoll, rollBand.SortOrder))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var cells = table.Results
|
||||||
|
.OrderBy(result => result.CriticalRollBand.SortOrder)
|
||||||
|
.ThenBy(result => result.CriticalGroup?.SortOrder ?? 0)
|
||||||
|
.ThenBy(result => result.CriticalColumn.SortOrder)
|
||||||
|
.Select(result => new CriticalTableCellDetail(
|
||||||
|
result.CriticalRollBand.Label,
|
||||||
|
result.CriticalColumn.ColumnKey,
|
||||||
|
result.CriticalColumn.Label,
|
||||||
|
result.CriticalColumn.Role,
|
||||||
|
result.CriticalGroup?.GroupKey,
|
||||||
|
result.CriticalGroup?.Label,
|
||||||
|
result.DescriptionText,
|
||||||
|
result.Effects
|
||||||
|
.OrderBy(effect => effect.Id)
|
||||||
|
.Select(effect => CreateEffectLookupResponse(effect))
|
||||||
|
.ToList(),
|
||||||
|
result.Branches
|
||||||
|
.OrderBy(branch => branch.SortOrder)
|
||||||
|
.Select(branch => CreateBranchLookupResponse(branch))
|
||||||
|
.ToList()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var legend = BuildLegend(cells);
|
||||||
|
|
||||||
|
return new CriticalTableDetail(
|
||||||
|
table.Slug,
|
||||||
|
table.DisplayName,
|
||||||
|
table.Family,
|
||||||
|
table.SourceDocument,
|
||||||
|
table.Notes,
|
||||||
|
columns,
|
||||||
|
groups,
|
||||||
|
rollBands,
|
||||||
|
cells,
|
||||||
|
legend);
|
||||||
|
}
|
||||||
|
private static IReadOnlyList<CriticalTableLegendEntry> BuildLegend(IReadOnlyList<CriticalTableCellDetail> cells)
|
||||||
|
{
|
||||||
|
var seenCodes = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var legend = new List<CriticalTableLegendEntry>();
|
||||||
|
|
||||||
|
foreach (var cell in cells)
|
||||||
|
{
|
||||||
|
var baseEffects = cell.Effects ?? Array.Empty<CriticalEffectLookupResponse>();
|
||||||
|
foreach (var effect in baseEffects)
|
||||||
|
{
|
||||||
|
TryAddLegendEntry(effect.EffectCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var branches = cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>();
|
||||||
|
foreach (var branch in branches)
|
||||||
|
{
|
||||||
|
var branchEffects = branch.Effects ?? Array.Empty<CriticalEffectLookupResponse>();
|
||||||
|
foreach (var effect in branchEffects)
|
||||||
|
{
|
||||||
|
TryAddLegendEntry(effect.EffectCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return legend
|
||||||
|
.OrderBy(item => item.Label, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
void TryAddLegendEntry(string effectCode)
|
||||||
|
{
|
||||||
|
if (!seenCodes.Add(effectCode))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AffixDisplayMap.TryGet(effectCode, out var info))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend.Add(new CriticalTableLegendEntry(effectCode, info.Symbol, info.Label, info.Description, info.Tooltip));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CriticalEffectLookupResponse CreateEffectLookupResponse(CriticalEffect effect) =>
|
||||||
|
new(
|
||||||
|
effect.EffectCode,
|
||||||
|
effect.Target,
|
||||||
|
effect.ValueInteger,
|
||||||
|
effect.ValueExpression,
|
||||||
|
effect.DurationRounds,
|
||||||
|
effect.PerRound,
|
||||||
|
effect.Modifier,
|
||||||
|
effect.BodyPart,
|
||||||
|
effect.IsPermanent,
|
||||||
|
effect.SourceType,
|
||||||
|
effect.SourceText);
|
||||||
|
|
||||||
|
private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) =>
|
||||||
|
new(
|
||||||
|
branch.BranchKind,
|
||||||
|
branch.ConditionKey,
|
||||||
|
branch.ConditionText,
|
||||||
|
branch.DescriptionText,
|
||||||
|
branch.RawAffixText,
|
||||||
|
(branch.Effects ?? Enumerable.Empty<CriticalEffect>())
|
||||||
|
.OrderBy(effect => effect.Id)
|
||||||
|
.Select(effect => CreateEffectLookupResponse(effect))
|
||||||
|
.ToList(),
|
||||||
|
branch.RawText,
|
||||||
|
branch.SortOrder);
|
||||||
|
|
||||||
private static string NormalizeSlug(string value) =>
|
private static string NormalizeSlug(string value) =>
|
||||||
value.Trim().Replace(' ', '_').ToLowerInvariant();
|
value.Trim().Replace(' ', '_').ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,6 +255,47 @@ textarea {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.critical-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-cell-description {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c1a10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-branch-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-branch-card {
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(127, 96, 55, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-branch-condition {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6b4c29;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-branch-description {
|
||||||
|
margin: 0.3rem 0 0.45rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #3b2a21;
|
||||||
|
}
|
||||||
|
|
||||||
.effect-stack {
|
.effect-stack {
|
||||||
margin-top: 0.85rem;
|
margin-top: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -298,6 +339,39 @@ textarea {
|
|||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.affix-badge-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affix-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(127, 96, 55, 0.18);
|
||||||
|
background: rgba(255, 250, 242, 0.9);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #5b4327;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affix-badge-symbol {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affix-badge-value {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affix-badge-fallback {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.error-text {
|
.error-text {
|
||||||
color: #8d2b1e;
|
color: #8d2b1e;
|
||||||
}
|
}
|
||||||
@@ -376,6 +450,108 @@ textarea {
|
|||||||
color: #fff7ee;
|
color: #fff7ee;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tables-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-shell {
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1.2rem;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
border: 1px solid rgba(127, 96, 55, 0.2);
|
||||||
|
box-shadow: 0 18px 30px rgba(41, 22, 11, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-shell header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-shell .table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-table th,
|
||||||
|
.critical-table td {
|
||||||
|
border: 1px solid rgba(127, 96, 55, 0.2);
|
||||||
|
padding: 0.45rem;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-table th {
|
||||||
|
background: rgba(238, 223, 193, 0.45);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-table td {
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-table td .critical-cell {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-table .roll-band-header {
|
||||||
|
width: 120px;
|
||||||
|
background: rgba(255, 247, 230, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-cell {
|
||||||
|
color: #a08464;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-legend {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(235, 226, 209, 0.6);
|
||||||
|
border: 1px solid rgba(127, 96, 55, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-symbol {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640.98px) {
|
@media (max-width: 640.98px) {
|
||||||
.content-shell {
|
.content-shell {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user