Improve campaign log roll cards

This commit is contained in:
2026-04-03 22:58:55 +02:00
parent 9581442cab
commit b26d58cea4
12 changed files with 353 additions and 36 deletions

View File

@@ -18,7 +18,7 @@
@foreach (var entry in CampaignLog)
{
var isExpanded = ExpandedRollId == entry.RollId;
<li class="log-entry @LogEntryCssClass(entry) @(isExpanded ? "expanded" : string.Empty)">
<li class="log-entry @LogEntryCssClass(entry, isExpanded, FreshRollId == entry.RollId)">
<button type="button"
class="log-entry-toggle"
aria-expanded="@isExpanded"
@@ -33,6 +33,19 @@
</span>
<span class="roll-total inline">@entry.Result</span>
</span>
@if (HasSummary(entry))
{
<span class="log-summary-row">
@foreach (var badge in GetEventBadges(entry))
{
<span class="log-event-badge @badge.Tone">@badge.Label</span>
}
@if (!string.IsNullOrWhiteSpace(entry.SummaryText))
{
<span class="log-summary-text">@entry.SummaryText</span>
}
</span>
}
<span class="log-meta"><span class="badge @entry.VisibilityStyle">@entry.VisibilityLabel</span>
<time
title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time>

View File

@@ -52,6 +52,9 @@ public partial class CampaignLogPanel
[Parameter]
public Guid? ExpandedRollId { get; set; }
[Parameter]
public Guid? FreshRollId { get; set; }
[Parameter]
public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
@@ -64,8 +67,46 @@ public partial class CampaignLogPanel
[Parameter]
public Func<Guid, string?> GetRollDetailError { get; set; } = _ => null;
private static string LogEntryCssClass(CampaignLogListEntry entry)
private static IReadOnlyList<EventBadgeView> GetEventBadges(CampaignLogListEntry entry)
{
return entry.VisibilityStyle;
return (entry.EventBadges ?? [])
.Select(ToEventBadgeView)
.Where(badge => badge is not null)
.Cast<EventBadgeView>()
.ToArray();
}
private static bool HasSummary(CampaignLogListEntry entry)
{
return (entry.EventBadges?.Length ?? 0) > 0 || !string.IsNullOrWhiteSpace(entry.SummaryText);
}
private static EventBadgeView? ToEventBadgeView(string code)
{
return code switch
{
"w6" => new EventBadgeView("Wild 6", "positive"),
"w1" => new EventBadgeView("Wild 1", "danger"),
"n20" => new EventBadgeView("Nat 20", "positive"),
"n1" => new EventBadgeView("Nat 1", "danger"),
"rf" => new EventBadgeView("Fumble", "danger"),
"r100" => new EventBadgeView("100", "rare"),
"r66" => new EventBadgeView("66", "rare"),
_ => null
};
}
private static string LogEntryCssClass(CampaignLogListEntry entry, bool isExpanded, bool isFresh)
{
var classes = new List<string> { entry.VisibilityStyle };
if (isExpanded)
classes.Add("expanded");
if (isFresh)
classes.Add("fresh");
return string.Join(" ", classes);
}
private sealed record EventBadgeView(string Label, string Tone);
}

View File

@@ -61,6 +61,7 @@
IsCampaignDataLoading="IsCampaignDataLoading"
CampaignLog="PlayVisibleCampaignLog"
ExpandedRollId="ExpandedCampaignLogRollId"
FreshRollId="FreshCampaignLogRollId"
ToggleRollDetailRequested="ToggleRollDetailAsync"
ResolveRollDetail="ResolveRollDetail"
IsRollDetailLoading="IsRollDetailLoading"

View File

@@ -181,7 +181,9 @@ public partial class Workspace : IAsyncDisposable
return;
}
var previousLogCount = CampaignLog.Count;
var page = await WorkspaceQuery.GetCampaignLogPageAsync(SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize);
Guid? newestRollId = null;
if (!afterRollId.HasValue || page.ResetRequired)
{
CampaignLog = page.Entries.ToList();
@@ -193,8 +195,32 @@ public partial class Workspace : IAsyncDisposable
CampaignLog = CampaignLog.TakeLast(CampaignLogWindowSize).ToList();
}
var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0;
if (!shouldAutoExpandNewest &&
!afterRollId.HasValue &&
CurrentCampaignState is not null &&
previousLogCount == 0 &&
page.Entries.Length > 0)
{
shouldAutoExpandNewest = true;
}
if (shouldAutoExpandNewest)
{
newestRollId = page.Entries[^1].RollId;
ExpandedCampaignLogRollId = newestRollId;
FreshCampaignLogRollId = newestRollId;
}
else if (!afterRollId.HasValue)
{
FreshCampaignLogRollId = null;
}
CampaignLogCursor = page.Cursor ?? afterRollId;
TrimCampaignLogDetails();
if (newestRollId.HasValue)
await EnsureRollDetailLoadedAsync(newestRollId.Value);
}
private async Task RefreshCampaignScopeAsync()
@@ -622,28 +648,14 @@ public partial class Workspace : IAsyncDisposable
if (ExpandedCampaignLogRollId == rollId)
{
ExpandedCampaignLogRollId = null;
if (FreshCampaignLogRollId == rollId)
FreshCampaignLogRollId = null;
return;
}
ExpandedCampaignLogRollId = rollId;
CampaignLogDetailErrors.Remove(rollId);
if (CampaignLogDetails.ContainsKey(rollId) || CampaignLogDetailsLoading.Contains(rollId))
return;
CampaignLogDetailsLoading.Add(rollId);
try
{
CampaignLogDetails[rollId] = await WorkspaceQuery.GetRollDetailAsync(rollId);
}
catch (ApiRequestException ex)
{
CampaignLogDetailErrors[rollId] = ex.Message;
}
finally
{
CampaignLogDetailsLoading.Remove(rollId);
await InvokeAsync(StateHasChanged);
}
FreshCampaignLogRollId = null;
await EnsureRollDetailLoadedAsync(rollId);
}
private async Task OnSkillCreatedAsync(Guid _)
@@ -706,8 +718,11 @@ public partial class Workspace : IAsyncDisposable
try
{
LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(RollVisibility));
CampaignLogDetails[LastRoll.RollId] = ToCampaignRollDetail(LastRoll);
CampaignLogDetailErrors.Remove(LastRoll.RollId);
await RefreshCampaignLogAsync(CampaignLogCursor);
PromoteFreshRoll(LastRoll.RollId);
ResetCampaignStateTracking();
SetStatus("Roll recorded.", false);
Announce("Roll result updated.");
@@ -949,6 +964,7 @@ public partial class Workspace : IAsyncDisposable
private void ResetCampaignLogDetailState()
{
ExpandedCampaignLogRollId = null;
FreshCampaignLogRollId = null;
CampaignLogDetails.Clear();
CampaignLogDetailsLoading.Clear();
CampaignLogDetailErrors.Clear();
@@ -969,6 +985,45 @@ public partial class Workspace : IAsyncDisposable
if (ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(ExpandedCampaignLogRollId.Value))
ExpandedCampaignLogRollId = null;
if (FreshCampaignLogRollId.HasValue && !visibleRollIds.Contains(FreshCampaignLogRollId.Value))
FreshCampaignLogRollId = null;
}
private async Task EnsureRollDetailLoadedAsync(Guid rollId)
{
CampaignLogDetailErrors.Remove(rollId);
if (CampaignLogDetails.ContainsKey(rollId) || CampaignLogDetailsLoading.Contains(rollId))
return;
CampaignLogDetailsLoading.Add(rollId);
try
{
CampaignLogDetails[rollId] = await WorkspaceQuery.GetRollDetailAsync(rollId);
}
catch (ApiRequestException ex)
{
CampaignLogDetailErrors[rollId] = ex.Message;
}
finally
{
CampaignLogDetailsLoading.Remove(rollId);
await InvokeAsync(StateHasChanged);
}
}
private static CampaignRollDetail ToCampaignRollDetail(RollResult roll)
{
return new CampaignRollDetail(roll.RollId, roll.Breakdown, roll.Dice.ToArray());
}
private void PromoteFreshRoll(Guid rollId)
{
if (!CampaignLog.Any(entry => entry.RollId == rollId))
return;
ExpandedCampaignLogRollId = rollId;
FreshCampaignLogRollId = rollId;
}
private void ClearAuthenticatedState()
@@ -1109,6 +1164,7 @@ public partial class Workspace : IAsyncDisposable
private CampaignStateSnapshot? CurrentCampaignState { get; set; }
private Guid? CampaignLogCursor { get; set; }
private Guid? ExpandedCampaignLogRollId { get; set; }
private Guid? FreshCampaignLogRollId { get; set; }
private Dictionary<Guid, CampaignRollDetail> CampaignLogDetails { get; } = [];
private HashSet<Guid> CampaignLogDetailsLoading { get; } = [];
private Dictionary<Guid, string> CampaignLogDetailErrors { get; } = [];

View File

@@ -104,7 +104,17 @@ public sealed record CharacterSheet(Guid CharacterId, CharacterSheetSkillGroup[]
public sealed record CampaignLogEntry(Guid RollId, Guid CampaignId, Guid CharacterId, string CharacterName, Guid SkillId, string SkillName, Guid RollerUserId, string RollerDisplayName, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
public sealed record CampaignLogListEntry(Guid RollId, string CharacterName, string SkillName, string RollerLabel, string VisibilityLabel, string VisibilityStyle, int Result, string SummaryText, DateTimeOffset TimestampUtc);
public sealed record CampaignLogListEntry(
Guid RollId,
string CharacterName,
string SkillName,
string RollerLabel,
string VisibilityLabel,
string VisibilityStyle,
int Result,
string SummaryText,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string[]? EventBadges,
DateTimeOffset TimestampUtc);
public sealed record CampaignRollDetail(Guid RollId, string Breakdown, RollDieResult[] Dice);

View File

@@ -1348,6 +1348,7 @@ public sealed class GameService : IGameService
var dice = DeserializeDice(entry.Dice);
var characterName = m_CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
var skillName = m_SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.Name : "Unknown skill";
var eventBadges = BuildCompactLogEventBadges(campaign, skill, dice);
return new(
entry.Id,
@@ -1358,6 +1359,7 @@ public sealed class GameService : IGameService
ResolveLogVisibilityStyle(user, campaign, entry),
entry.Result,
BuildCompactLogSummary(dice),
eventBadges,
entry.TimestampUtc);
}
@@ -1378,17 +1380,7 @@ public sealed class GameService : IGameService
if (dice.Count > 3)
preview = $"{preview}, ...";
var tags = new List<string>();
if (dice.Any(die => die.Wild))
tags.Add("wild");
if (dice.Any(die => die.Crit))
tags.Add("crit");
if (dice.Any(die => die.Fumble))
tags.Add("fumble");
return tags.Count == 0 ? preview : $"{preview} | {string.Join(", ", tags)}";
return preview;
}
private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> dice)
@@ -1433,6 +1425,55 @@ public sealed class GameService : IGameService
RollDieKinds.RolemasterOpenEndedLowSubtract;
}
private static string[]? BuildCompactLogEventBadges(Campaign campaign, Skill? skill, IReadOnlyList<RollDieResult> dice)
{
var badges = new List<string>();
switch (campaign.Ruleset)
{
case RulesetKind.D6:
AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 6), "w6");
AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 1), "w1");
break;
case RulesetKind.Dnd5e:
if (skill is not null && IsSingleD20Expression(skill.DiceRollDefinition))
{
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 20), "n20");
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 1), "n1");
}
break;
case RulesetKind.Rolemaster:
AddBadgeIfMissing(
badges,
dice.Any(die =>
string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) &&
!die.SignedContribution.HasValue),
"rf");
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 100), "r100");
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 66), "r66");
break;
}
return badges.Count == 0 ? null : badges.ToArray();
}
private static void AddBadgeIfMissing(List<string> badges, bool condition, string code)
{
if (!condition || badges.Any(badge => string.Equals(badge, code, StringComparison.Ordinal)))
return;
badges.Add(code);
}
private static bool IsSingleD20Expression(string expression)
{
var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression);
return parsedExpression.Succeeded &&
parsedExpression.Value!.DiceCount == 1 &&
parsedExpression.Value.Sides == 20;
}
private bool CanViewRollLocked(UserAccount user, Campaign campaign, RollLogEntry entry)
{
return CanViewCampaignLocked(user.Id, campaign.Id) &&

View File

@@ -662,6 +662,11 @@ select:focus-visible {
border-color: color-mix(in srgb, var(--accent) 38%, var(--card-border) 62%);
}
.log-entry.fresh {
border-color: color-mix(in srgb, #c79913 52%, var(--card-border) 48%);
box-shadow: 0 0.9rem 1.8rem rgba(199, 153, 19, 0.16);
}
.log-entry-toggle:hover {
background: color-mix(in srgb, var(--card) 84%, #ffffff 16%);
}
@@ -699,6 +704,51 @@ select:focus-visible {
color: var(--muted);
}
.log-summary-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
min-width: 0;
}
.log-summary-text {
color: var(--muted);
font-size: 0.92rem;
line-height: 1.3;
}
.log-event-badge {
display: inline-flex;
align-items: center;
min-height: 1.5rem;
padding: 0.12rem 0.5rem;
border-radius: 999px;
border: 1px solid transparent;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
}
.log-event-badge.positive {
border-color: #79a85d;
background: #e7f6da;
color: #235217;
}
.log-event-badge.danger {
border-color: #c56b5a;
background: #ffe3dc;
color: #7d1f17;
}
.log-event-badge.rare {
border-color: #b48b34;
background: #fff1c7;
color: #6d4c05;
}
.log-meta {
display: flex;
justify-content: space-between;