-
- Critical table
-
- @foreach (var criticalTable in referenceData.CriticalTables)
- {
- @criticalTable.Label
- }
-
-
-
-
- Severity
-
- @foreach (var column in SelectedCriticalTable?.Columns ?? [])
- {
- @column.Label
- }
-
-
-
- @if (SelectedCriticalTable?.Groups.Count > 0)
- {
+
}
@@ -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;
}
-}
+
+}
\ No newline at end of file
diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor
index 3a4bc78..21ddc6b 100644
--- a/src/RolemasterDb.App/Components/Pages/Tables.razor
+++ b/src/RolemasterDb.App/Components/Pages/Tables.razor
@@ -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);
diff --git a/src/RolemasterDb.App/Components/Play/PlayResultActions.razor b/src/RolemasterDb.App/Components/Play/PlayResultActions.razor
new file mode 100644
index 0000000..d05c0f9
--- /dev/null
+++ b/src/RolemasterDb.App/Components/Play/PlayResultActions.razor
@@ -0,0 +1,13 @@
+@if (!string.IsNullOrWhiteSpace(Href))
+{
+
+}
+
+@code {
+
+ [Parameter]
+ public string? Href { get; set; }
+
+}
\ No newline at end of file
diff --git a/src/RolemasterDb.App/Components/_Imports.razor b/src/RolemasterDb.App/Components/_Imports.razor
index 58bb4ad..0d63f30 100644
--- a/src/RolemasterDb.App/Components/_Imports.razor
+++ b/src/RolemasterDb.App/Components/_Imports.razor
@@ -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
diff --git a/src/RolemasterDb.App/Frontend/AppState/TableContextCellResolver.cs b/src/RolemasterDb.App/Frontend/AppState/TableContextCellResolver.cs
new file mode 100644
index 0000000..85dbcbd
--- /dev/null
+++ b/src/RolemasterDb.App/Frontend/AppState/TableContextCellResolver.cs
@@ -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));
+ }
+}
\ No newline at end of file
diff --git a/src/RolemasterDb.App/Frontend/Curation/CurationQueueResolver.cs b/src/RolemasterDb.App/Frontend/Curation/CurationQueueResolver.cs
index 1767b32..eeb5c8a 100644
--- a/src/RolemasterDb.App/Frontend/Curation/CurationQueueResolver.cs
+++ b/src/RolemasterDb.App/Frontend/Curation/CurationQueueResolver.cs
@@ -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);
diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css
index 4ad7621..6bcc917 100644
--- a/src/RolemasterDb.App/wwwroot/app.css
+++ b/src/RolemasterDb.App/wwwroot/app.css
@@ -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;
diff --git a/src/RolemasterDb.ImportTool.Tests/CurationQueueResolverTests.cs b/src/RolemasterDb.ImportTool.Tests/CurationQueueResolverTests.cs
index 46ebddb..2c44a3f 100644
--- a/src/RolemasterDb.ImportTool.Tests/CurationQueueResolverTests.cs
+++ b/src/RolemasterDb.ImportTool.Tests/CurationQueueResolverTests.cs
@@ -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),
diff --git a/src/RolemasterDb.ImportTool.Tests/TableContextCellResolverTests.cs b/src/RolemasterDb.ImportTool.Tests/TableContextCellResolverTests.cs
new file mode 100644
index 0000000..830adf4
--- /dev/null
+++ b/src/RolemasterDb.ImportTool.Tests/TableContextCellResolverTests.cs
@@ -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, []);
+}
\ No newline at end of file