Align Play with table deep links

This commit is contained in:
2026-04-12 20:05:20 +02:00
parent 0c3e10a5ca
commit 1a058143bb
10 changed files with 538 additions and 258 deletions

View File

@@ -30,7 +30,7 @@ It is intentionally implementation-focused:
- Branch: `frontend/tables-overhaul` - Branch: `frontend/tables-overhaul`
- Last updated: `2026-04-12` - Last updated: `2026-04-12`
- Current focus: `Phase 6` - Current focus: `Phase 7`
- Document mode: living plan and progress log - Document mode: living plan and progress log
### Progress Log ### Progress Log
@@ -78,6 +78,7 @@ It is intentionally implementation-focused:
| 2026-04-12 | Phase 4 planning | Planned | Expanded the `Curation` phase from a route placeholder into a concrete migration plan that moves queue-first curation out of `Tables` and into a dedicated workflow surface. | | 2026-04-12 | Phase 4 planning | Planned | Expanded the `Curation` phase from a route placeholder into a concrete migration plan that moves queue-first curation out of `Tables` and into a dedicated workflow surface. |
| 2026-04-12 | Phase 4 | Completed | Replaced the placeholder `/curation` route with a real queue-first workspace, added queue scope and context persistence, moved browse-to-curation handoff out of `Tables`, and preserved diagnostics and full-editor escape hatches without keeping queue work on the reference page. | | 2026-04-12 | Phase 4 | Completed | Replaced the placeholder `/curation` route with a real queue-first workspace, added queue scope and context persistence, moved browse-to-curation handoff out of `Tables`, and preserved diagnostics and full-editor escape hatches without keeping queue work on the reference page. |
| 2026-04-12 | Phase 5 | Completed | Consolidated the existing tooling routes into a coherent `Tools` workspace with a real hub, shared tooling page frame, preserved-context exits from diagnostics back into `Tables` and `Curation`, and a grouped API reference surface. | | 2026-04-12 | Phase 5 | Completed | Consolidated the existing tooling routes into a coherent `Tools` workspace with a real hub, shared tooling page frame, preserved-context exits from diagnostics back into `Tables` and `Curation`, and a grouped API reference surface. |
| 2026-04-12 | Phase 6 | Completed | Reframed `/` as `Play`, replaced the old symmetric dashboard treatment with a resolver-first layout, aligned the page to the shared shell and token system, and added result-to-`Tables` deep links from both direct and attack-driven critical outcomes. |
### Lessons Learned ### Lessons Learned

View File

@@ -1,9 +1,11 @@
@page "/" @page "/"
@rendermode InteractiveServer @rendermode InteractiveServer
@using System @using System
@using RolemasterDb.App.Frontend.AppState
@inject LookupService LookupService @inject LookupService LookupService
@inject TableContextState TableContextState
<PageTitle>Lookup Desk</PageTitle> <PageTitle>Play</PageTitle>
@if (referenceData is null) @if (referenceData is null)
{ {
@@ -13,210 +15,256 @@
} }
else else
{ {
<div class="dashboard-grid"> <div class="play-page">
<section class="panel"> <header class="panel play-hero">
<h2 class="panel-title">Attack Lookup</h2> <div class="play-hero-copy">
<p class="panel-copy">Choose an attack, armor type, and attack roll. If it produces a critical and you enter the follow-up roll, the app resolves that too.</p> <p class="play-eyebrow">Play</p>
<h1 class="play-title">Resolve attacks fast. Open the table only when you need context.</h1>
<div class="lookup-form"> <p class="play-summary">Use the attack lane for live results and the direct critical lane when you already know the exact table and severity.</p>
<div class="form-grid">
<div class="field-shell">
<label for="attack-table">Attack table</label>
<select id="attack-table" class="input-shell" value="@attackInput.AttackTable" @onchange="HandleAttackTableChanged">
@foreach (var attackTable in referenceData.AttackTables)
{
<option value="@attackTable.Key">@attackTable.Label</option>
}
</select>
</div>
<div class="field-shell">
<label for="armor-type">Armor type</label>
<select id="armor-type" class="input-shell" @bind="attackInput.ArmorType" @bind:after="HandleAttackInputsChanged">
@foreach (var armorType in referenceData.ArmorTypes)
{
<option value="@armorType.Key">@armorType.Label</option>
}
</select>
</div>
<div class="field-shell">
<label for="attack-roll">Attack roll</label>
<div class="roll-input-row">
<input id="attack-roll" class="input-shell" type="number" min="1" @bind="attackInput.AttackRoll" @bind:after="HandleAttackInputsChanged" />
<button type="button" class="roll-button" @onclick="RollAttack">Roll</button>
</div>
@if (SelectedAttackTable is { FumbleMinRoll: int attackFumbleMin, FumbleMaxRoll: int attackFumbleMax })
{
<p class="muted lookup-roll-note">Fumble range: @FormatRange(attackFumbleMin, attackFumbleMax)</p>
}
@if (!string.IsNullOrWhiteSpace(attackRollSummary))
{
<p class="muted lookup-roll-note">@attackRollSummary</p>
}
@if (!string.IsNullOrWhiteSpace(attackFumbleMessage))
{
<p class="lookup-roll-note is-warning">@attackFumbleMessage</p>
}
</div>
<div class="field-shell">
<label for="attack-ob">OB</label>
<input id="attack-ob" class="input-shell" type="number" @bind="attackInput.OffensiveBonus" @bind:after="HandleAttackInputsChanged" />
</div>
<div class="field-shell">
<label for="attack-db">DB</label>
<input id="attack-db" class="input-shell" type="number" @bind="attackInput.DefensiveBonus" @bind:after="HandleAttackInputsChanged" />
</div>
<div class="field-shell">
<label for="critical-roll">Critical roll</label>
<div class="roll-input-row">
<input id="critical-roll" class="input-shell" type="number" min="1" @bind="attackInput.CriticalRollText" />
<button type="button" class="roll-button" @onclick="RollAttackCriticalAsync">Roll</button>
</div>
@if (!string.IsNullOrWhiteSpace(attackCriticalRollSummary))
{
<p class="muted lookup-roll-note">@attackCriticalRollSummary</p>
}
</div>
</div>
<div class="action-row">
<button class="btn-ritual" @onclick="RunAttackLookupAsync">Resolve attack</button>
<span class="muted">Leave critical roll blank if you only need the hit result.</span>
</div>
</div> </div>
<div class="play-hero-meta" aria-label="Play page workflow">
<span class="tag">Live lookup</span>
<span class="tag">Fast resolution</span>
<span class="tag">Table deep links</span>
</div>
</header>
@if (!string.IsNullOrWhiteSpace(attackError)) <div class="play-layout">
{ <section class="panel play-panel play-panel-primary">
<p class="error-text">@attackError</p> <div class="play-panel-header">
} <div class="play-panel-copy">
<p class="play-eyebrow">Primary lane</p>
@if (attackResult is not null) <h2 class="panel-title">Attack lookup</h2>
{
<div class="result-shell">
<div class="result-card">
<h3>@attackResult.AttackTableName vs @attackResult.ArmorTypeLabel</h3>
<div class="result-stats">
@if (lastAttackResolution is not null)
{
<span class="stat-pill">Rolled: @lastAttackResolution.RawRoll</span>
@if (lastAttackResolution.OffensiveBonus != 0)
{
<span class="stat-pill">OB: +@lastAttackResolution.OffensiveBonus</span>
}
@if (lastAttackResolution.DefensiveBonus != 0)
{
<span class="stat-pill">DB: -@lastAttackResolution.DefensiveBonus</span>
}
<span class="stat-pill">Total used: @lastAttackResolution.EffectiveTotal</span>
}
else
{
<span class="stat-pill">Attack total: @attackResult.Roll</span>
}
<span class="stat-pill">Hits: @attackResult.Hits</span>
@if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity))
{
<span class="stat-pill">@attackResult.CriticalSeverity @attackResult.CriticalType critical</span>
}
else
{
<span class="stat-pill">No critical</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(attackResult.Notes))
{
<p class="muted">@attackResult.Notes</p>
}
@if (attackResult.AutoCritical is not null)
{
<div class="callout">
<h4>Resolved critical</h4>
<CriticalLookupResultCard Result="attackResult.AutoCritical" />
</div>
}
else if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity))
{
<div class="callout">The attack produced a critical. Add a critical roll to resolve it automatically.</div>
}
</div> </div>
</div> </div>
}
</section>
<section class="panel"> <div class="lookup-form">
<h2 class="panel-title">Direct Critical Lookup</h2> <div class="form-grid play-form-grid play-form-grid-primary">
<p class="panel-copy">Use this when you already know the critical table, severity, roll, and variant if the table uses one.</p>
<div class="lookup-form">
<div class="form-grid">
<div class="field-shell">
<label for="critical-table">Critical table</label>
<select id="critical-table" class="input-shell" value="@criticalInput.CriticalType" @onchange="HandleCriticalTableChanged">
@foreach (var criticalTable in referenceData.CriticalTables)
{
<option value="@criticalTable.Key">@criticalTable.Label</option>
}
</select>
</div>
<div class="field-shell">
<label for="critical-column">Severity</label>
<select id="critical-column" class="input-shell" @bind="criticalInput.Column">
@foreach (var column in SelectedCriticalTable?.Columns ?? [])
{
<option value="@column.Key">@column.Label</option>
}
</select>
</div>
@if (SelectedCriticalTable?.Groups.Count > 0)
{
<div class="field-shell"> <div class="field-shell">
<label for="critical-group">Variant</label> <label for="attack-table">Attack table</label>
<select id="critical-group" class="input-shell" @bind="criticalInput.Group"> <select id="attack-table" class="input-shell" value="@attackInput.AttackTable" @onchange="HandleAttackTableChanged">
@foreach (var group in SelectedCriticalTable.Groups) @foreach (var attackTable in referenceData.AttackTables)
{ {
<option value="@group.Key">@group.Label</option> <option value="@attackTable.Key">@attackTable.Label</option>
} }
</select> </select>
</div> </div>
}
<div class="field-shell"> <div class="field-shell">
<label for="critical-roll-direct">Critical roll</label> <label for="armor-type">Armor type</label>
<div class="roll-input-row"> <select id="armor-type" class="input-shell" @bind="attackInput.ArmorType" @bind:after="HandleAttackInputsChanged">
<input id="critical-roll-direct" class="input-shell" type="number" min="1" @bind="criticalInput.Roll" /> @foreach (var armorType in referenceData.ArmorTypes)
<button type="button" class="roll-button" @onclick="RollDirectCritical">Roll</button> {
<option value="@armorType.Key">@armorType.Label</option>
}
</select>
</div> </div>
@if (!string.IsNullOrWhiteSpace(directCriticalRollSummary))
{ <div class="field-shell">
<p class="muted lookup-roll-note">@directCriticalRollSummary</p> <label for="attack-roll">Attack roll</label>
} <div class="roll-input-row">
<input id="attack-roll" class="input-shell" type="number" min="1" @bind="attackInput.AttackRoll" @bind:after="HandleAttackInputsChanged"/>
<button type="button" class="roll-button" @onclick="RollAttack">Roll</button>
</div>
@if (SelectedAttackTable is { FumbleMinRoll: int attackFumbleMin, FumbleMaxRoll: int attackFumbleMax })
{
<p class="muted lookup-roll-note">Fumble range: @FormatRange(attackFumbleMin, attackFumbleMax)</p>
}
@if (!string.IsNullOrWhiteSpace(attackRollSummary))
{
<p class="muted lookup-roll-note">@attackRollSummary</p>
}
@if (!string.IsNullOrWhiteSpace(attackFumbleMessage))
{
<p class="lookup-roll-note is-warning">@attackFumbleMessage</p>
}
</div>
<div class="field-shell">
<label for="attack-ob">OB</label>
<input id="attack-ob" class="input-shell" type="number" @bind="attackInput.OffensiveBonus" @bind:after="HandleAttackInputsChanged"/>
</div>
<div class="field-shell">
<label for="attack-db">DB</label>
<input id="attack-db" class="input-shell" type="number" @bind="attackInput.DefensiveBonus" @bind:after="HandleAttackInputsChanged"/>
</div>
<div class="field-shell">
<label for="critical-roll">Critical roll</label>
<div class="roll-input-row">
<input id="critical-roll" class="input-shell" type="number" min="1" @bind="attackInput.CriticalRollText"/>
<button type="button" class="roll-button" @onclick="RollAttackCriticalAsync">Roll</button>
</div>
@if (!string.IsNullOrWhiteSpace(attackCriticalRollSummary))
{
<p class="muted lookup-roll-note">@attackCriticalRollSummary</p>
}
</div>
</div>
<div class="action-row play-action-row">
<button class="btn-ritual" @onclick="RunAttackLookupAsync">Resolve attack</button>
<span class="muted play-action-hint">Leave critical roll blank if you only need the hit result.</span>
</div> </div>
</div> </div>
<div class="action-row"> @if (!string.IsNullOrWhiteSpace(attackError))
<button class="btn-ritual" @onclick="RunCriticalLookupAsync">Resolve critical</button> {
</div> <p class="error-text">@attackError</p>
</div> }
@if (!string.IsNullOrWhiteSpace(criticalError)) @if (attackResult is not null)
{ {
<p class="error-text">@criticalError</p> <div class="result-shell play-result-dock">
} <div class="result-card play-result-card">
<div class="play-result-heading">
<div>
<p class="play-result-eyebrow">Attack result</p>
<h3>@attackResult.AttackTableName vs @attackResult.ArmorTypeLabel</h3>
</div>
</div>
@if (criticalResult is not null) <div class="result-stats">
{ @if (lastAttackResolution is not null)
<div class="result-shell"> {
<CriticalLookupResultCard Result="criticalResult" /> <span class="stat-pill">Rolled: @lastAttackResolution.RawRoll</span>
@if (lastAttackResolution.OffensiveBonus != 0)
{
<span class="stat-pill">OB: +@lastAttackResolution.OffensiveBonus</span>
}
@if (lastAttackResolution.DefensiveBonus != 0)
{
<span class="stat-pill">DB: -@lastAttackResolution.DefensiveBonus</span>
}
<span class="stat-pill">Total used: @lastAttackResolution.EffectiveTotal</span>
}
else
{
<span class="stat-pill">Attack total: @attackResult.Roll</span>
}
<span class="stat-pill">Hits: @attackResult.Hits</span>
@if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity))
{
<span class="stat-pill">@attackResult.CriticalSeverity @attackResult.CriticalType critical</span>
}
else
{
<span class="stat-pill">No critical</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(attackResult.Notes))
{
<p class="muted play-result-note">@attackResult.Notes</p>
}
@if (attackResult.AutoCritical is not null)
{
<div class="callout play-callout">
<div class="play-result-heading">
<div>
<p class="play-result-eyebrow">Follow-up critical</p>
<h4>Resolved critical</h4>
</div>
<PlayResultActions Href="@BuildTablesHref(attackResult.AutoCritical)"/>
</div>
<CriticalLookupResultCard Result="attackResult.AutoCritical"/>
</div>
}
else if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity))
{
<div class="callout play-callout">
The attack produced a critical. Add a critical roll to resolve it automatically.
</div>
}
</div>
</div>
}
</section>
<section class="panel play-panel play-panel-secondary">
<div class="play-panel-header">
<div class="play-panel-copy">
<p class="play-eyebrow">Quick jump</p>
<h2 class="panel-title">Direct critical lookup</h2>
</div>
</div> </div>
}
</section> <div class="lookup-form">
<div class="form-grid play-form-grid">
<div class="field-shell">
<label for="critical-table">Critical table</label>
<select id="critical-table" class="input-shell" value="@criticalInput.CriticalType" @onchange="HandleCriticalTableChanged">
@foreach (var criticalTable in referenceData.CriticalTables)
{
<option value="@criticalTable.Key">@criticalTable.Label</option>
}
</select>
</div>
<div class="field-shell">
<label for="critical-column">Severity</label>
<select id="critical-column" class="input-shell" @bind="criticalInput.Column">
@foreach (var column in SelectedCriticalTable?.Columns ?? [])
{
<option value="@column.Key">@column.Label</option>
}
</select>
</div>
@if (SelectedCriticalTable?.Groups.Count > 0)
{
<div class="field-shell">
<label for="critical-group">Variant</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>
<div class="roll-input-row">
<input id="critical-roll-direct" class="input-shell" type="number" min="1" @bind="criticalInput.Roll"/>
<button type="button" class="roll-button" @onclick="RollDirectCritical">Roll</button>
</div>
@if (!string.IsNullOrWhiteSpace(directCriticalRollSummary))
{
<p class="muted lookup-roll-note">@directCriticalRollSummary</p>
}
</div>
</div>
<div class="action-row play-action-row">
<button class="btn-ritual" @onclick="RunCriticalLookupAsync">Resolve critical</button>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(criticalError))
{
<p class="error-text">@criticalError</p>
}
@if (criticalResult is not null)
{
<div class="result-shell play-result-dock">
<div class="play-result-heading">
<div>
<p class="play-result-eyebrow">Critical result</p>
</div>
<PlayResultActions Href="@BuildTablesHref(criticalResult)"/>
</div>
<CriticalLookupResultCard Result="criticalResult"/>
</div>
}
</section>
</div>
</div> </div>
} }
@@ -271,11 +319,7 @@ else
return; return;
} }
var response = await LookupService.LookupAttackAsync(new AttackLookupRequest( var response = await LookupService.LookupAttackAsync(new AttackLookupRequest(attackInput.AttackTable, attackInput.ArmorType, attackResolution.EffectiveTotal, string.IsNullOrWhiteSpace(attackInput.CriticalRollText) ? null : criticalRoll));
attackInput.AttackTable,
attackInput.ArmorType,
attackResolution.EffectiveTotal,
string.IsNullOrWhiteSpace(attackInput.CriticalRollText) ? null : criticalRoll));
if (response is null) if (response is null)
{ {
@@ -292,11 +336,7 @@ else
criticalError = null; criticalError = null;
criticalResult = null; criticalResult = null;
var response = await LookupService.LookupCriticalAsync(new CriticalLookupRequest( var response = await LookupService.LookupCriticalAsync(new CriticalLookupRequest(criticalInput.CriticalType, criticalInput.Column, criticalInput.Roll, SelectedCriticalTable?.Groups.Count > 0 ? criticalInput.Group : null));
criticalInput.CriticalType,
criticalInput.Column,
criticalInput.Roll,
SelectedCriticalTable?.Groups.Count > 0 ? criticalInput.Group : null));
if (response is null) if (response is null)
{ {
@@ -347,16 +387,11 @@ else
} }
CriticalTableReference? criticalTable = null; CriticalTableReference? criticalTable = null;
var pendingAttack = await LookupService.LookupAttackAsync(new AttackLookupRequest( var pendingAttack = await LookupService.LookupAttackAsync(new AttackLookupRequest(attackInput.AttackTable, attackInput.ArmorType, attackResolution.EffectiveTotal, null));
attackInput.AttackTable,
attackInput.ArmorType,
attackResolution.EffectiveTotal,
null));
if (!string.IsNullOrWhiteSpace(pendingAttack?.CriticalType)) if (!string.IsNullOrWhiteSpace(pendingAttack?.CriticalType))
{ {
criticalTable = referenceData?.CriticalTables.FirstOrDefault(item => criticalTable = referenceData?.CriticalTables.FirstOrDefault(item => string.Equals(item.Key, pendingAttack.CriticalType, StringComparison.Ordinal));
string.Equals(item.Key, pendingAttack.CriticalType, StringComparison.Ordinal));
} }
var result = RolemasterRoller.RollCritical(Random.Shared, criticalTable); var result = RolemasterRoller.RollCritical(Random.Shared, criticalTable);
@@ -394,15 +429,11 @@ else
} }
private AttackResolutionSummary BuildAttackResolution() => private AttackResolutionSummary BuildAttackResolution() =>
AttackResolutionCalculator.Resolve( AttackResolutionCalculator.Resolve(attackInput.AttackRoll, attackInput.OffensiveBonus, attackInput.DefensiveBonus);
attackInput.AttackRoll,
attackInput.OffensiveBonus,
attackInput.DefensiveBonus);
private string? BuildAttackFumbleMessage(AttackResolutionSummary resolution) private string? BuildAttackFumbleMessage(AttackResolutionSummary resolution)
{ {
if (!AttackResolutionCalculator.IsFumble(resolution, SelectedAttackTable) || if (!AttackResolutionCalculator.IsFumble(resolution, SelectedAttackTable) || SelectedAttackTable is not { FumbleMinRoll: int fumbleMinRoll, FumbleMaxRoll: int fumbleMaxRoll })
SelectedAttackTable is not { FumbleMinRoll: int fumbleMinRoll, FumbleMaxRoll: int fumbleMaxRoll })
{ {
return null; return null;
} }
@@ -442,9 +473,7 @@ else
return $"{summary} {string.Join(' ', modifiers)} = {resolution.EffectiveTotal}."; return $"{summary} {string.Join(' ', modifiers)} = {resolution.EffectiveTotal}.";
} }
private static string BuildCriticalRollSummary( private static string BuildCriticalRollSummary(LookupRollResult result, CriticalTableReference? criticalTable)
LookupRollResult result,
CriticalTableReference? criticalTable)
{ {
var summary = BuildRollSummary(result, "Critical"); var summary = BuildRollSummary(result, "Critical");
if (criticalTable is null) if (criticalTable is null)
@@ -452,14 +481,30 @@ else
return $"{summary} Standard 1-100 roll used because no critical table is currently resolved from the attack result."; return $"{summary} Standard 1-100 roll used because no critical table is currently resolved from the attack result.";
} }
return RolemasterRoller.AllowsOpenEndedCritical(criticalTable) return RolemasterRoller.AllowsOpenEndedCritical(criticalTable) ? $"{summary} {criticalTable.Label} uses open-ended rolls." : $"{summary} {criticalTable.Label} is capped at 1-100.";
? $"{summary} {criticalTable.Label} uses open-ended rolls."
: $"{summary} {criticalTable.Label} is capped at 1-100.";
} }
private static string FormatRange(int minRoll, int maxRoll) => private static string FormatRange(int minRoll, int maxRoll) =>
$"{minRoll:00}-{maxRoll:00}"; $"{minRoll:00}-{maxRoll:00}";
private string? BuildTablesHref(CriticalLookupResponse? result)
{
if (referenceData is null || result is null)
{
return null;
}
var table = referenceData.CriticalTables.FirstOrDefault(item => string.Equals(item.Key, result.CriticalType, StringComparison.Ordinal));
if (table is null)
{
return null;
}
var snapshot = new TableContextSnapshot(TableSlug: table.Key, GroupKey: result.Group, ColumnKey: result.Column, RollBand: result.RollBand, Mode: TableContextMode.Reference);
return TableContextState.BuildUri("/tables", snapshot);
}
private sealed class AttackLookupForm private sealed class AttackLookupForm
{ {
public string AttackTable { get; set; } = string.Empty; public string AttackTable { get; set; } = string.Empty;
@@ -477,4 +522,5 @@ else
public string Group { 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

@@ -149,7 +149,7 @@
await PersistAndSyncTableContextAsync(); await PersistAndSyncTableContextAsync();
} }
private async Task LoadTableDetailAsync() private async Task LoadTableDetailAsync(TableContextSnapshot? routeContext = null)
{ {
if (string.IsNullOrWhiteSpace(selectedTableSlug)) if (string.IsNullOrWhiteSpace(selectedTableSlug))
{ {
@@ -172,6 +172,7 @@
} }
await RecordRecentTableVisitAsync(); await RecordRecentTableVisitAsync();
ApplyRouteContext(routeContext);
NormalizeViewStateForCurrentDetail(); NormalizeViewStateForCurrentDetail();
} }
catch (Exception exception) catch (Exception exception)
@@ -203,12 +204,14 @@
if (string.IsNullOrWhiteSpace(selectedTableSlug) || !string.Equals(resolvedTableSlug, selectedTableSlug, StringComparison.OrdinalIgnoreCase)) if (string.IsNullOrWhiteSpace(selectedTableSlug) || !string.Equals(resolvedTableSlug, selectedTableSlug, StringComparison.OrdinalIgnoreCase))
{ {
selectedTableSlug = resolvedTableSlug; selectedTableSlug = resolvedTableSlug;
await LoadTableDetailAsync(); await LoadTableDetailAsync(initialContext);
await PersistAndSyncTableContextAsync(); await PersistAndSyncTableContextAsync();
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
return; return;
} }
ApplyRouteContext(initialContext);
NormalizeViewStateForCurrentDetail();
await PersistAndSyncTableContextAsync(); await PersistAndSyncTableContextAsync();
} }
} }
@@ -373,7 +376,7 @@
} }
private RolemasterDb.App.Frontend.AppState.TableContextSnapshot BuildCurrentTableContext() => private RolemasterDb.App.Frontend.AppState.TableContextSnapshot BuildCurrentTableContext() =>
new(TableSlug: selectedTableSlug, Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Reference); new(TableSlug: selectedTableSlug, GroupKey: selectedCell?.GroupKey, ColumnKey: selectedCell?.ColumnKey, RollBand: selectedCell?.RollBand, ResultId: selectedCell?.ResultId, Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Reference);
private void SelectCell(TablesCellSelection selection) private void SelectCell(TablesCellSelection selection)
{ {
@@ -408,6 +411,18 @@
return Task.CompletedTask; return Task.CompletedTask;
} }
private void ApplyRouteContext(TableContextSnapshot? routeContext)
{
if (tableDetail is null)
{
selectedCell = null;
return;
}
var resolvedCell = TableContextCellResolver.FindCell(tableDetail, routeContext);
selectedCell = resolvedCell is null ? null : new TablesCellSelection(resolvedCell.ResultId, resolvedCell.RollBand, resolvedCell.ColumnKey, resolvedCell.GroupKey);
}
private void NormalizeViewStateForCurrentDetail() private void NormalizeViewStateForCurrentDetail()
{ {
referenceMode = NormalizeMode(referenceMode); referenceMode = NormalizeMode(referenceMode);

View File

@@ -0,0 +1,13 @@
@if (!string.IsNullOrWhiteSpace(Href))
{
<div class="play-result-actions">
<a class="play-action-link" href="@Href">Open in Tables</a>
</div>
}
@code {
[Parameter]
public string? Href { get; set; }
}

View File

@@ -12,6 +12,7 @@
@using RolemasterDb.App.Components @using RolemasterDb.App.Components
@using RolemasterDb.App.Components.Curation @using RolemasterDb.App.Components.Curation
@using RolemasterDb.App.Components.Layout @using RolemasterDb.App.Components.Layout
@using RolemasterDb.App.Components.Play
@using RolemasterDb.App.Components.Primitives @using RolemasterDb.App.Components.Primitives
@using RolemasterDb.App.Components.Shell @using RolemasterDb.App.Components.Shell
@using RolemasterDb.App.Components.Shared @using RolemasterDb.App.Components.Shared

View File

@@ -0,0 +1,32 @@
using RolemasterDb.App.Features;
namespace RolemasterDb.App.Frontend.AppState;
public static class TableContextCellResolver
{
public static CriticalTableCellDetail? FindCell(CriticalTableDetail detail, TableContextSnapshot? context)
{
ArgumentNullException.ThrowIfNull(detail);
if (context is null)
{
return null;
}
if (context.ResultId is { } resultId)
{
var matchedByResultId = detail.Cells.FirstOrDefault(cell => cell.ResultId == resultId);
if (matchedByResultId is not null)
{
return matchedByResultId;
}
}
if (string.IsNullOrWhiteSpace(context.RollBand) && string.IsNullOrWhiteSpace(context.ColumnKey) && string.IsNullOrWhiteSpace(context.GroupKey))
{
return null;
}
return detail.Cells.FirstOrDefault(cell => string.Equals(cell.RollBand, context.RollBand, StringComparison.Ordinal) && string.Equals(cell.ColumnKey, context.ColumnKey, StringComparison.Ordinal) && string.Equals(cell.GroupKey ?? string.Empty, context.GroupKey ?? string.Empty, StringComparison.Ordinal));
}
}

View File

@@ -36,31 +36,8 @@ public static class CurationQueueResolver
}).ThenBy(cell => columnOrder.GetValueOrDefault(cell.ColumnKey, int.MaxValue)).ToList(); }).ThenBy(cell => columnOrder.GetValueOrDefault(cell.ColumnKey, int.MaxValue)).ToList();
} }
public static CriticalTableCellDetail? FindCell(CriticalTableDetail detail, TableContextSnapshot? context) public static CriticalTableCellDetail? FindCell(CriticalTableDetail detail, TableContextSnapshot? context) =>
{ TableContextCellResolver.FindCell(detail, context);
ArgumentNullException.ThrowIfNull(detail);
if (context is null)
{
return null;
}
if (context.ResultId is { } resultId)
{
var matchedByResultId = detail.Cells.FirstOrDefault(cell => cell.ResultId == resultId);
if (matchedByResultId is not null)
{
return matchedByResultId;
}
}
if (string.IsNullOrWhiteSpace(context.RollBand) && string.IsNullOrWhiteSpace(context.ColumnKey) && string.IsNullOrWhiteSpace(context.GroupKey))
{
return null;
}
return detail.Cells.FirstOrDefault(cell => string.Equals(cell.RollBand, context.RollBand, StringComparison.Ordinal) && string.Equals(cell.ColumnKey, context.ColumnKey, StringComparison.Ordinal) && string.Equals(cell.GroupKey ?? string.Empty, context.GroupKey ?? string.Empty, StringComparison.Ordinal));
}
public static CriticalTableCellDetail? FindFirstUncurated(CriticalTableDetail detail) => public static CriticalTableCellDetail? FindFirstUncurated(CriticalTableDetail detail) =>
GetOrderedCells(detail).FirstOrDefault(cell => !cell.IsCurated); GetOrderedCells(detail).FirstOrDefault(cell => !cell.IsCurated);

View File

@@ -268,6 +268,147 @@ pre,
font-family: var(--font-mono); font-family: var(--font-mono);
} }
.play-page,
.play-hero,
.play-hero-copy,
.play-layout,
.play-panel,
.play-panel-header,
.play-panel-copy,
.play-result-dock,
.play-result-card,
.play-result-heading,
.play-result-actions {
display: grid;
gap: 1rem;
}
.play-page {
gap: 1.25rem;
}
.play-hero {
gap: 1.25rem;
background:
radial-gradient(circle at top right, color-mix(in srgb, var(--accent-2) 24%, transparent), transparent 36%),
linear-gradient(180deg, color-mix(in srgb, var(--surface-card-subtle) 92%, var(--surface-1)), var(--surface-1));
}
.play-hero-copy {
gap: 0.65rem;
max-width: 58rem;
}
.play-eyebrow,
.play-result-eyebrow {
margin: 0;
color: var(--accent-strong);
font-family: var(--font-ui);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.play-title {
margin: 0;
font-size: clamp(2.1rem, 4vw, 3.4rem);
line-height: 1;
max-width: 17ch;
}
.play-summary,
.play-action-hint,
.play-result-note {
margin: 0;
color: var(--text-secondary);
}
.play-hero-meta {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
align-items: center;
}
.play-layout {
grid-template-columns: minmax(0, 1.7fr) minmax(19rem, 1fr);
align-items: start;
}
.play-panel {
align-content: start;
min-width: 0;
}
.play-panel-header {
gap: 0.5rem;
}
.play-panel-copy {
gap: 0.35rem;
}
.play-form-grid {
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
}
.play-form-grid-primary {
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
}
.play-action-row {
justify-content: space-between;
}
.play-result-dock {
padding-top: 0.1rem;
}
.play-result-card {
gap: 1rem;
}
.play-result-heading {
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.75rem;
}
.play-result-heading h3,
.play-result-heading h4 {
margin: 0;
}
.play-result-actions {
align-items: center;
justify-items: end;
}
.play-action-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--control-height);
padding: 0.72rem 1rem;
border-radius: 999px;
border: 1px solid var(--button-secondary-border);
background: var(--button-secondary-bg);
color: var(--button-secondary-text);
text-decoration: none;
white-space: nowrap;
}
.play-action-link:hover {
background: var(--button-secondary-bg-hover);
color: var(--button-secondary-text);
}
.play-callout {
display: grid;
gap: 0.85rem;
}
.dashboard-grid { .dashboard-grid {
display: grid; display: grid;
gap: 1.25rem; gap: 1.25rem;
@@ -615,6 +756,30 @@ select.input-shell {
margin-bottom: 0; margin-bottom: 0;
} }
@media (max-width: 900px) {
.play-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.play-title {
max-width: none;
}
.play-result-heading {
grid-template-columns: 1fr;
}
.play-result-actions {
justify-items: start;
}
.play-action-row {
justify-content: flex-start;
}
}
.critical-cell { .critical-cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -36,18 +36,6 @@ public sealed class CurationQueueResolverTests
Assert.Null(CurationQueueResolver.FindNextUncurated(detail, 3)); Assert.Null(CurationQueueResolver.FindNextUncurated(detail, 3));
} }
[Fact]
public void Find_cell_prefers_result_id_and_falls_back_to_location_context()
{
var detail = CreateDetail(new CriticalTableCellDetail(11, "01-05", "A", "A", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(12, "06-10", "B", "B", "severity", "A", "Alpha", false, null, [], []));
var byResult = CurationQueueResolver.FindCell(detail, new TableContextSnapshot(ResultId: 12));
var byLocation = CurationQueueResolver.FindCell(detail, new TableContextSnapshot(GroupKey: "A", ColumnKey: "B", RollBand: "06-10"));
Assert.Equal(12, byResult!.ResultId);
Assert.Equal(12, byLocation!.ResultId);
}
private static CriticalTableDetail CreateDetail(params CriticalTableCellDetail[] cells) => private static CriticalTableDetail CreateDetail(params CriticalTableCellDetail[] cells) =>
new("slash", "Slash Critical Strike Table", "standard", "Slash.pdf", null, [ new("slash", "Slash Critical Strike Table", "standard", "Slash.pdf", null, [
new CriticalColumnReference("A", "A", "severity", 1), new CriticalColumnReference("A", "A", "severity", 1),

View File

@@ -0,0 +1,42 @@
using RolemasterDb.App.Features;
using RolemasterDb.App.Frontend.AppState;
namespace RolemasterDb.ImportTool.Tests;
public sealed class TableContextCellResolverTests
{
[Fact]
public void Find_cell_prefers_result_id_and_falls_back_to_location_context()
{
var detail = CreateDetail(new CriticalTableCellDetail(11, "01-05", "A", "A", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(12, "06-10", "B", "B", "severity", "A", "Alpha", false, null, [], []));
var byResult = TableContextCellResolver.FindCell(detail, new TableContextSnapshot(ResultId: 12));
var byLocation = TableContextCellResolver.FindCell(detail, new TableContextSnapshot(GroupKey: "A", ColumnKey: "B", RollBand: "06-10"));
Assert.Equal(12, byResult!.ResultId);
Assert.Equal(12, byLocation!.ResultId);
}
[Fact]
public void Find_cell_returns_null_when_context_does_not_identify_a_cell()
{
var detail = CreateDetail(new CriticalTableCellDetail(12, "06-10", "B", "B", "severity", "A", "Alpha", false, null, [], []));
Assert.Null(TableContextCellResolver.FindCell(detail, new TableContextSnapshot()));
Assert.Null(TableContextCellResolver.FindCell(detail, new TableContextSnapshot(GroupKey: "B", ColumnKey: "B", RollBand: "06-10")));
}
private static CriticalTableDetail CreateDetail(params CriticalTableCellDetail[] cells) =>
new("slash", "Slash Critical Strike Table", "standard", "Slash.pdf", null, [
new CriticalColumnReference("A", "A", "severity", 1),
new CriticalColumnReference("B", "B", "severity", 2),
new CriticalColumnReference("C", "C", "severity", 3)
], [
new CriticalGroupReference("A", "Alpha", 1),
new CriticalGroupReference("B", "Beta", 2)
], [
new CriticalRollBandReference("01-05", 1, 5, 1),
new CriticalRollBandReference("06-10", 6, 10, 2),
new CriticalRollBandReference("21-25", 21, 25, 3)
], cells, []);
}