Align Play with table deep links
This commit is contained in:
@@ -30,7 +30,7 @@ It is intentionally implementation-focused:
|
||||
|
||||
- Branch: `frontend/tables-overhaul`
|
||||
- Last updated: `2026-04-12`
|
||||
- Current focus: `Phase 6`
|
||||
- Current focus: `Phase 7`
|
||||
- Document mode: living plan and 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 | 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 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
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
@page "/"
|
||||
@rendermode InteractiveServer
|
||||
@using System
|
||||
@using RolemasterDb.App.Frontend.AppState
|
||||
@inject LookupService LookupService
|
||||
@inject TableContextState TableContextState
|
||||
|
||||
<PageTitle>Lookup Desk</PageTitle>
|
||||
<PageTitle>Play</PageTitle>
|
||||
|
||||
@if (referenceData is null)
|
||||
{
|
||||
@@ -13,210 +15,256 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-grid">
|
||||
<section class="panel">
|
||||
<h2 class="panel-title">Attack Lookup</h2>
|
||||
<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>
|
||||
|
||||
<div class="lookup-form">
|
||||
<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 class="play-page">
|
||||
<header class="panel play-hero">
|
||||
<div class="play-hero-copy">
|
||||
<p class="play-eyebrow">Play</p>
|
||||
<h1 class="play-title">Resolve attacks fast. Open the table only when you need context.</h1>
|
||||
<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>
|
||||
<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))
|
||||
{
|
||||
<p class="error-text">@attackError</p>
|
||||
}
|
||||
|
||||
@if (attackResult is not null)
|
||||
{
|
||||
<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 class="play-layout">
|
||||
<section class="panel play-panel play-panel-primary">
|
||||
<div class="play-panel-header">
|
||||
<div class="play-panel-copy">
|
||||
<p class="play-eyebrow">Primary lane</p>
|
||||
<h2 class="panel-title">Attack lookup</h2>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2 class="panel-title">Direct Critical Lookup</h2>
|
||||
<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="lookup-form">
|
||||
<div class="form-grid play-form-grid play-form-grid-primary">
|
||||
<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)
|
||||
<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="@group.Key">@group.Label</option>
|
||||
<option value="@attackTable.Key">@attackTable.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 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>
|
||||
@if (!string.IsNullOrWhiteSpace(directCriticalRollSummary))
|
||||
{
|
||||
<p class="muted lookup-roll-note">@directCriticalRollSummary</p>
|
||||
}
|
||||
|
||||
<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 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 class="action-row">
|
||||
<button class="btn-ritual" @onclick="RunCriticalLookupAsync">Resolve critical</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(attackError))
|
||||
{
|
||||
<p class="error-text">@attackError</p>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(criticalError))
|
||||
{
|
||||
<p class="error-text">@criticalError</p>
|
||||
}
|
||||
@if (attackResult is not null)
|
||||
{
|
||||
<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-shell">
|
||||
<CriticalLookupResultCard Result="criticalResult" />
|
||||
<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 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>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
|
||||
@@ -271,11 +319,7 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
var response = await LookupService.LookupAttackAsync(new AttackLookupRequest(
|
||||
attackInput.AttackTable,
|
||||
attackInput.ArmorType,
|
||||
attackResolution.EffectiveTotal,
|
||||
string.IsNullOrWhiteSpace(attackInput.CriticalRollText) ? null : criticalRoll));
|
||||
var response = await LookupService.LookupAttackAsync(new AttackLookupRequest(attackInput.AttackTable, attackInput.ArmorType, attackResolution.EffectiveTotal, string.IsNullOrWhiteSpace(attackInput.CriticalRollText) ? null : criticalRoll));
|
||||
|
||||
if (response is null)
|
||||
{
|
||||
@@ -292,11 +336,7 @@ else
|
||||
criticalError = null;
|
||||
criticalResult = null;
|
||||
|
||||
var response = await LookupService.LookupCriticalAsync(new CriticalLookupRequest(
|
||||
criticalInput.CriticalType,
|
||||
criticalInput.Column,
|
||||
criticalInput.Roll,
|
||||
SelectedCriticalTable?.Groups.Count > 0 ? criticalInput.Group : null));
|
||||
var response = await LookupService.LookupCriticalAsync(new CriticalLookupRequest(criticalInput.CriticalType, criticalInput.Column, criticalInput.Roll, SelectedCriticalTable?.Groups.Count > 0 ? criticalInput.Group : null));
|
||||
|
||||
if (response is null)
|
||||
{
|
||||
@@ -347,16 +387,11 @@ else
|
||||
}
|
||||
|
||||
CriticalTableReference? criticalTable = null;
|
||||
var pendingAttack = await LookupService.LookupAttackAsync(new AttackLookupRequest(
|
||||
attackInput.AttackTable,
|
||||
attackInput.ArmorType,
|
||||
attackResolution.EffectiveTotal,
|
||||
null));
|
||||
var pendingAttack = await LookupService.LookupAttackAsync(new AttackLookupRequest(attackInput.AttackTable, attackInput.ArmorType, attackResolution.EffectiveTotal, null));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(pendingAttack?.CriticalType))
|
||||
{
|
||||
criticalTable = referenceData?.CriticalTables.FirstOrDefault(item =>
|
||||
string.Equals(item.Key, pendingAttack.CriticalType, StringComparison.Ordinal));
|
||||
criticalTable = referenceData?.CriticalTables.FirstOrDefault(item => string.Equals(item.Key, pendingAttack.CriticalType, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
var result = RolemasterRoller.RollCritical(Random.Shared, criticalTable);
|
||||
@@ -394,15 +429,11 @@ else
|
||||
}
|
||||
|
||||
private AttackResolutionSummary BuildAttackResolution() =>
|
||||
AttackResolutionCalculator.Resolve(
|
||||
attackInput.AttackRoll,
|
||||
attackInput.OffensiveBonus,
|
||||
attackInput.DefensiveBonus);
|
||||
AttackResolutionCalculator.Resolve(attackInput.AttackRoll, attackInput.OffensiveBonus, attackInput.DefensiveBonus);
|
||||
|
||||
private string? BuildAttackFumbleMessage(AttackResolutionSummary resolution)
|
||||
{
|
||||
if (!AttackResolutionCalculator.IsFumble(resolution, SelectedAttackTable) ||
|
||||
SelectedAttackTable is not { FumbleMinRoll: int fumbleMinRoll, FumbleMaxRoll: int fumbleMaxRoll })
|
||||
if (!AttackResolutionCalculator.IsFumble(resolution, SelectedAttackTable) || SelectedAttackTable is not { FumbleMinRoll: int fumbleMinRoll, FumbleMaxRoll: int fumbleMaxRoll })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -442,9 +473,7 @@ else
|
||||
return $"{summary} {string.Join(' ', modifiers)} = {resolution.EffectiveTotal}.";
|
||||
}
|
||||
|
||||
private static string BuildCriticalRollSummary(
|
||||
LookupRollResult result,
|
||||
CriticalTableReference? criticalTable)
|
||||
private static string BuildCriticalRollSummary(LookupRollResult result, CriticalTableReference? criticalTable)
|
||||
{
|
||||
var summary = BuildRollSummary(result, "Critical");
|
||||
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 RolemasterRoller.AllowsOpenEndedCritical(criticalTable)
|
||||
? $"{summary} {criticalTable.Label} uses open-ended rolls."
|
||||
: $"{summary} {criticalTable.Label} is capped at 1-100.";
|
||||
return RolemasterRoller.AllowsOpenEndedCritical(criticalTable) ? $"{summary} {criticalTable.Label} uses open-ended rolls." : $"{summary} {criticalTable.Label} is capped at 1-100.";
|
||||
}
|
||||
|
||||
private static string FormatRange(int minRoll, int maxRoll) =>
|
||||
$"{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
|
||||
{
|
||||
public string AttackTable { get; set; } = string.Empty;
|
||||
@@ -477,4 +522,5 @@ else
|
||||
public string Group { get; set; } = string.Empty;
|
||||
public int Roll { get; set; } = 72;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -149,7 +149,7 @@
|
||||
await PersistAndSyncTableContextAsync();
|
||||
}
|
||||
|
||||
private async Task LoadTableDetailAsync()
|
||||
private async Task LoadTableDetailAsync(TableContextSnapshot? routeContext = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selectedTableSlug))
|
||||
{
|
||||
@@ -172,6 +172,7 @@
|
||||
}
|
||||
|
||||
await RecordRecentTableVisitAsync();
|
||||
ApplyRouteContext(routeContext);
|
||||
NormalizeViewStateForCurrentDetail();
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -203,12 +204,14 @@
|
||||
if (string.IsNullOrWhiteSpace(selectedTableSlug) || !string.Equals(resolvedTableSlug, selectedTableSlug, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
selectedTableSlug = resolvedTableSlug;
|
||||
await LoadTableDetailAsync();
|
||||
await LoadTableDetailAsync(initialContext);
|
||||
await PersistAndSyncTableContextAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyRouteContext(initialContext);
|
||||
NormalizeViewStateForCurrentDetail();
|
||||
await PersistAndSyncTableContextAsync();
|
||||
}
|
||||
}
|
||||
@@ -373,7 +376,7 @@
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -408,6 +411,18 @@
|
||||
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()
|
||||
{
|
||||
referenceMode = NormalizeMode(referenceMode);
|
||||
|
||||
13
src/RolemasterDb.App/Components/Play/PlayResultActions.razor
Normal file
13
src/RolemasterDb.App/Components/Play/PlayResultActions.razor
Normal 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; }
|
||||
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
@using RolemasterDb.App.Components
|
||||
@using RolemasterDb.App.Components.Curation
|
||||
@using RolemasterDb.App.Components.Layout
|
||||
@using RolemasterDb.App.Components.Play
|
||||
@using RolemasterDb.App.Components.Primitives
|
||||
@using RolemasterDb.App.Components.Shell
|
||||
@using RolemasterDb.App.Components.Shared
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -36,31 +36,8 @@ public static class CurationQueueResolver
|
||||
}).ThenBy(cell => columnOrder.GetValueOrDefault(cell.ColumnKey, int.MaxValue)).ToList();
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
public static CriticalTableCellDetail? FindCell(CriticalTableDetail detail, TableContextSnapshot? context) =>
|
||||
TableContextCellResolver.FindCell(detail, context);
|
||||
|
||||
public static CriticalTableCellDetail? FindFirstUncurated(CriticalTableDetail detail) =>
|
||||
GetOrderedCells(detail).FirstOrDefault(cell => !cell.IsCurated);
|
||||
|
||||
@@ -268,6 +268,147 @@ pre,
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
@@ -615,6 +756,30 @@ select.input-shell {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -36,18 +36,6 @@ public sealed class CurationQueueResolverTests
|
||||
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) =>
|
||||
new("slash", "Slash Critical Strike Table", "standard", "Slash.pdf", null, [
|
||||
new CriticalColumnReference("A", "A", "severity", 1),
|
||||
|
||||
@@ -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, []);
|
||||
}
|
||||
Reference in New Issue
Block a user