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

@@ -128,7 +128,8 @@ dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.cs
- Workspace reads are resolved server-side through scoped query services; browser interop remains for browser-only concerns such as session storage, SSE wiring, and DOM helpers. - Workspace reads are resolved server-side through scoped query services; browser interop remains for browser-only concerns such as session storage, SSE wiring, and DOM helpers.
- Live workspace refreshes now compare separate roster, per-character sheet, and log versions so unrelated state changes do not force a full roster + sheet + log reload. - Live workspace refreshes now compare separate roster, per-character sheet, and log versions so unrelated state changes do not force a full roster + sheet + log reload.
- Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and a 25-row incremental log window backed by `/api/campaigns/{campaignId}/log/page`. - Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and a 25-row incremental log window backed by `/api/campaigns/{campaignId}/log/page`.
- Campaign log rows now ship compact summary data first and lazy-load dice + breakdown detail through `/api/rolls/{rollId}` only when a row is expanded. - Campaign log rows now ship compact summary data first, including structured special-event badges for wild dice spikes, natural 1/20 results, and Rolemaster rare/fumble triggers, and lazy-load dice + breakdown detail through `/api/rolls/{rollId}` only when a row is expanded.
- Newly appended campaign-log rolls auto-expand in the play workspace, with the roll response reused as the initial detail payload for the local roller to avoid an extra detail fetch.
- Hot API contracts share a source-generated `System.Text.Json` context, and HTTP JSON responses are gzip-compressed when the client advertises support. - Hot API contracts share a source-generated `System.Text.Json` context, and HTTP JSON responses are gzip-compressed when the client advertises support.
- OpenAPI contract source remains at `openapi/RpgRoller.json`. - OpenAPI contract source remains at `openapi/RpgRoller.json`.

View File

@@ -34,7 +34,9 @@ public sealed class RolemasterApiTests : ApiTestBase
Assert.Equal(2, logPage.Entries.Length); Assert.Equal(2, logPage.Entries.Length);
Assert.Equal("8 + 6 | rolemaster", logPage.Entries[0].SummaryText); Assert.Equal("8 + 6 | rolemaster", logPage.Entries[0].SummaryText);
Assert.Null(logPage.Entries[0].EventBadges);
Assert.Equal("74 | rolemaster", logPage.Entries[1].SummaryText); Assert.Equal("74 | rolemaster", logPage.Entries[1].SummaryText);
Assert.Null(logPage.Entries[1].EventBadges);
} }
[Fact] [Fact]
@@ -56,7 +58,13 @@ public sealed class RolemasterApiTests : ApiTestBase
Assert.Equal(-124, roll.Result); Assert.Equal(-124, roll.Result);
Assert.Equal("(05) -97 -100 -12 +85 = -124", roll.Breakdown); Assert.Equal("(05) -97 -100 -12 +85 = -124", roll.Breakdown);
Assert.Equal("(05) -97 -100 -12 | open-ended low", Assert.Single(logPage.Entries).SummaryText); var logEntry = Assert.Single(logPage.Entries);
Assert.Equal("(05) -97 -100 -12 | open-ended low", logEntry.SummaryText);
var eventBadges = Assert.IsType<string[]>(logEntry.EventBadges);
Assert.Collection(
eventBadges,
badge => Assert.Equal("rf", badge),
badge => Assert.Equal("r100", badge));
Assert.Equal(roll.Breakdown, detail.Breakdown); Assert.Equal(roll.Breakdown, detail.Breakdown);
Assert.Collection( Assert.Collection(
detail.Dice, detail.Dice,

View File

@@ -56,6 +56,7 @@ public sealed class ServiceRolemasterRollTests
Assert.Equal(58, roll.Result); Assert.Equal(58, roll.Result);
Assert.Equal("73-15=58", roll.Breakdown); Assert.Equal("73-15=58", roll.Breakdown);
Assert.Equal("73 | rolemaster", Assert.Single(logPage.Entries).SummaryText); Assert.Equal("73 | rolemaster", Assert.Single(logPage.Entries).SummaryText);
Assert.Null(Assert.Single(logPage.Entries).EventBadges);
var die = Assert.Single(roll.Dice); var die = Assert.Single(roll.Dice);
Assert.Equal(73, die.Roll); Assert.Equal(73, die.Roll);
@@ -83,6 +84,7 @@ public sealed class ServiceRolemasterRollTests
Assert.Equal(323, roll.Result); Assert.Equal(323, roll.Result);
Assert.Equal("97+96+45+85=323", roll.Breakdown); Assert.Equal("97+96+45+85=323", roll.Breakdown);
Assert.Equal("97 + 96 + 45 | open-ended high", Assert.Single(logPage.Entries).SummaryText); Assert.Equal("97 + 96 + 45 | open-ended high", Assert.Single(logPage.Entries).SummaryText);
Assert.Null(Assert.Single(logPage.Entries).EventBadges);
Assert.Equal(roll.Breakdown, detail.Breakdown); Assert.Equal(roll.Breakdown, detail.Breakdown);
Assert.Collection( Assert.Collection(
detail.Dice, detail.Dice,
@@ -129,7 +131,13 @@ public sealed class ServiceRolemasterRollTests
Assert.Equal(-124, roll.Result); Assert.Equal(-124, roll.Result);
Assert.Equal("(05) -97 -100 -12 +85 = -124", roll.Breakdown); Assert.Equal("(05) -97 -100 -12 +85 = -124", roll.Breakdown);
Assert.Equal("(05) -97 -100 -12 | open-ended low", Assert.Single(logPage.Entries).SummaryText); var logEntry = Assert.Single(logPage.Entries);
Assert.Equal("(05) -97 -100 -12 | open-ended low", logEntry.SummaryText);
var lowEventBadges = Assert.IsType<string[]>(logEntry.EventBadges);
Assert.Collection(
lowEventBadges,
badge => Assert.Equal("rf", badge),
badge => Assert.Equal("r100", badge));
Assert.Collection( Assert.Collection(
roll.Dice, roll.Dice,
die => die =>
@@ -161,4 +169,24 @@ public sealed class ServiceRolemasterRollTests
Assert.Equal(-12, die.SignedContribution); Assert.Equal(-12, die.SignedContribution);
}); });
} }
[Fact]
public void RollSkill_RolemasterSixtySix_AddsRareBadgeToLogSummary()
{
using var harness = ServiceTestSupport.CreateHarness(66);
var service = harness.Service;
service.Register("gm-sixty-six", "Password123", "GM");
var session = ServiceTestSupport.GetValue(service.Login("gm-sixty-six", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Perception", "d100-15", 0, false));
_ = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
var badge = Assert.Single(Assert.IsType<string[]>(logEntry.EventBadges));
Assert.Equal("r66", badge);
Assert.Equal("66 | rolemaster", logEntry.SummaryText);
}
} }

View File

@@ -148,6 +148,44 @@ public sealed class ServiceSkillRollTests
Assert.Equal(rollIds[^1], gapPage.Cursor); Assert.Equal(rollIds[^1], gapPage.Cursor);
} }
[Fact]
public void CampaignLogPage_BuildsD6AndDndSpecialEventBadges()
{
using var harness = ServiceTestSupport.CreateHarness(6, 4, 6, 6, 2, 20, 1);
var service = harness.Service;
service.Register("gm-special", "Password123", "GM");
service.Register("owner-special", "Password123", "Owner");
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-special", "Password123")).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-special", "Password123")).SessionToken;
var d6Campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "D6 Special", "d6"));
var d6Character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Wild Hero", d6Campaign.Id));
var d6Skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, d6Character.Id, "Stealth", "2D+1", 1, true));
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, d6Skill.Id, "public"));
var d6Entry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, d6Campaign.Id, limit: 5)).Entries);
var d6Badges = Assert.IsType<string[]>(d6Entry.EventBadges);
Assert.Equal("w6", Assert.Single(d6Badges));
Assert.Equal("6, 4, 6, ...", d6Entry.SummaryText);
var dndCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Dnd Special", "dnd5e"));
var dndCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Natural Hero", dndCampaign.Id));
var dndSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, dndCharacter.Id, "Attack", "1d20+5", 0, false));
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, dndSkill.Id, "public"));
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, dndSkill.Id, "public"));
var dndEntries = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, dndCampaign.Id, limit: 5)).Entries;
var firstDndBadges = Assert.IsType<string[]>(dndEntries[0].EventBadges);
Assert.Equal("n20", Assert.Single(firstDndBadges));
Assert.Equal("20", dndEntries[0].SummaryText);
var secondDndBadges = Assert.IsType<string[]>(dndEntries[1].EventBadges);
Assert.Equal("n1", Assert.Single(secondDndBadges));
Assert.Equal("1", dndEntries[1].SummaryText);
}
[Fact] [Fact]
public void RollDetail_ReturnsVisibleDetailAndHidesPrivateRoll() public void RollDetail_ReturnsVisibleDetailAndHidesPrivateRoll()
{ {

View File

@@ -18,7 +18,7 @@
@foreach (var entry in CampaignLog) @foreach (var entry in CampaignLog)
{ {
var isExpanded = ExpandedRollId == entry.RollId; 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" <button type="button"
class="log-entry-toggle" class="log-entry-toggle"
aria-expanded="@isExpanded" aria-expanded="@isExpanded"
@@ -33,6 +33,19 @@
</span> </span>
<span class="roll-total inline">@entry.Result</span> <span class="roll-total inline">@entry.Result</span>
</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> <span class="log-meta"><span class="badge @entry.VisibilityStyle">@entry.VisibilityLabel</span>
<time <time
title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time> title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time>

View File

@@ -52,6 +52,9 @@ public partial class CampaignLogPanel
[Parameter] [Parameter]
public Guid? ExpandedRollId { get; set; } public Guid? ExpandedRollId { get; set; }
[Parameter]
public Guid? FreshRollId { get; set; }
[Parameter] [Parameter]
public EventCallback<Guid> ToggleRollDetailRequested { get; set; } public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
@@ -64,8 +67,46 @@ public partial class CampaignLogPanel
[Parameter] [Parameter]
public Func<Guid, string?> GetRollDetailError { get; set; } = _ => null; 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" IsCampaignDataLoading="IsCampaignDataLoading"
CampaignLog="PlayVisibleCampaignLog" CampaignLog="PlayVisibleCampaignLog"
ExpandedRollId="ExpandedCampaignLogRollId" ExpandedRollId="ExpandedCampaignLogRollId"
FreshRollId="FreshCampaignLogRollId"
ToggleRollDetailRequested="ToggleRollDetailAsync" ToggleRollDetailRequested="ToggleRollDetailAsync"
ResolveRollDetail="ResolveRollDetail" ResolveRollDetail="ResolveRollDetail"
IsRollDetailLoading="IsRollDetailLoading" IsRollDetailLoading="IsRollDetailLoading"

View File

@@ -181,7 +181,9 @@ public partial class Workspace : IAsyncDisposable
return; return;
} }
var previousLogCount = CampaignLog.Count;
var page = await WorkspaceQuery.GetCampaignLogPageAsync(SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize); var page = await WorkspaceQuery.GetCampaignLogPageAsync(SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize);
Guid? newestRollId = null;
if (!afterRollId.HasValue || page.ResetRequired) if (!afterRollId.HasValue || page.ResetRequired)
{ {
CampaignLog = page.Entries.ToList(); CampaignLog = page.Entries.ToList();
@@ -193,8 +195,32 @@ public partial class Workspace : IAsyncDisposable
CampaignLog = CampaignLog.TakeLast(CampaignLogWindowSize).ToList(); 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; CampaignLogCursor = page.Cursor ?? afterRollId;
TrimCampaignLogDetails(); TrimCampaignLogDetails();
if (newestRollId.HasValue)
await EnsureRollDetailLoadedAsync(newestRollId.Value);
} }
private async Task RefreshCampaignScopeAsync() private async Task RefreshCampaignScopeAsync()
@@ -622,28 +648,14 @@ public partial class Workspace : IAsyncDisposable
if (ExpandedCampaignLogRollId == rollId) if (ExpandedCampaignLogRollId == rollId)
{ {
ExpandedCampaignLogRollId = null; ExpandedCampaignLogRollId = null;
if (FreshCampaignLogRollId == rollId)
FreshCampaignLogRollId = null;
return; return;
} }
ExpandedCampaignLogRollId = rollId; ExpandedCampaignLogRollId = rollId;
CampaignLogDetailErrors.Remove(rollId); FreshCampaignLogRollId = null;
if (CampaignLogDetails.ContainsKey(rollId) || CampaignLogDetailsLoading.Contains(rollId)) await EnsureRollDetailLoadedAsync(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 async Task OnSkillCreatedAsync(Guid _) private async Task OnSkillCreatedAsync(Guid _)
@@ -706,8 +718,11 @@ public partial class Workspace : IAsyncDisposable
try try
{ {
LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(RollVisibility)); 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); await RefreshCampaignLogAsync(CampaignLogCursor);
PromoteFreshRoll(LastRoll.RollId);
ResetCampaignStateTracking(); ResetCampaignStateTracking();
SetStatus("Roll recorded.", false); SetStatus("Roll recorded.", false);
Announce("Roll result updated."); Announce("Roll result updated.");
@@ -949,6 +964,7 @@ public partial class Workspace : IAsyncDisposable
private void ResetCampaignLogDetailState() private void ResetCampaignLogDetailState()
{ {
ExpandedCampaignLogRollId = null; ExpandedCampaignLogRollId = null;
FreshCampaignLogRollId = null;
CampaignLogDetails.Clear(); CampaignLogDetails.Clear();
CampaignLogDetailsLoading.Clear(); CampaignLogDetailsLoading.Clear();
CampaignLogDetailErrors.Clear(); CampaignLogDetailErrors.Clear();
@@ -969,6 +985,45 @@ public partial class Workspace : IAsyncDisposable
if (ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(ExpandedCampaignLogRollId.Value)) if (ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(ExpandedCampaignLogRollId.Value))
ExpandedCampaignLogRollId = null; 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() private void ClearAuthenticatedState()
@@ -1109,6 +1164,7 @@ public partial class Workspace : IAsyncDisposable
private CampaignStateSnapshot? CurrentCampaignState { get; set; } private CampaignStateSnapshot? CurrentCampaignState { get; set; }
private Guid? CampaignLogCursor { get; set; } private Guid? CampaignLogCursor { get; set; }
private Guid? ExpandedCampaignLogRollId { get; set; } private Guid? ExpandedCampaignLogRollId { get; set; }
private Guid? FreshCampaignLogRollId { get; set; }
private Dictionary<Guid, CampaignRollDetail> CampaignLogDetails { get; } = []; private Dictionary<Guid, CampaignRollDetail> CampaignLogDetails { get; } = [];
private HashSet<Guid> CampaignLogDetailsLoading { get; } = []; private HashSet<Guid> CampaignLogDetailsLoading { get; } = [];
private Dictionary<Guid, string> CampaignLogDetailErrors { 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 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); 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 dice = DeserializeDice(entry.Dice);
var characterName = m_CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character"; 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 skillName = m_SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.Name : "Unknown skill";
var eventBadges = BuildCompactLogEventBadges(campaign, skill, dice);
return new( return new(
entry.Id, entry.Id,
@@ -1358,6 +1359,7 @@ public sealed class GameService : IGameService
ResolveLogVisibilityStyle(user, campaign, entry), ResolveLogVisibilityStyle(user, campaign, entry),
entry.Result, entry.Result,
BuildCompactLogSummary(dice), BuildCompactLogSummary(dice),
eventBadges,
entry.TimestampUtc); entry.TimestampUtc);
} }
@@ -1378,17 +1380,7 @@ public sealed class GameService : IGameService
if (dice.Count > 3) if (dice.Count > 3)
preview = $"{preview}, ..."; preview = $"{preview}, ...";
var tags = new List<string>(); return preview;
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)}";
} }
private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> dice) private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> dice)
@@ -1433,6 +1425,55 @@ public sealed class GameService : IGameService
RollDieKinds.RolemasterOpenEndedLowSubtract; 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) private bool CanViewRollLocked(UserAccount user, Campaign campaign, RollLogEntry entry)
{ {
return CanViewCampaignLocked(user.Id, campaign.Id) && 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%); 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 { .log-entry-toggle:hover {
background: color-mix(in srgb, var(--card) 84%, #ffffff 16%); background: color-mix(in srgb, var(--card) 84%, #ffffff 16%);
} }
@@ -699,6 +704,51 @@ select:focus-visible {
color: var(--muted); 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 { .log-meta {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@@ -58,6 +58,7 @@ test("Rolemaster open-ended roll detail renders specialized dice chips", async (
await page.goto("/"); await page.goto("/");
await expect(page.getByText("Campaign Log")).toBeVisible(); await expect(page.getByText("Campaign Log")).toBeVisible();
await expect(page.locator(".log-panel .log-event-badge")).toHaveText("Fumble");
const logEntry = page.locator(".log-panel .log-entry-toggle").first(); const logEntry = page.locator(".log-panel .log-entry-toggle").first();
await expect(logEntry).toBeVisible(); await expect(logEntry).toBeVisible();
@@ -68,6 +69,35 @@ test("Rolemaster open-ended roll detail renders specialized dice chips", async (
await expect(page.locator(".log-detail .roll-dice-strip")).toBeVisible(); await expect(page.locator(".log-detail .roll-dice-strip")).toBeVisible();
}); });
test("newly rolled log entry auto-expands", async ({ page, context }) => {
const username = `d6-log-${Date.now()}`;
await registerAndLogin(context.request, username, "D6 Auto Expand");
const campaign = await postJson(context.request, "/api/campaigns", {
name: "D6 Auto Expand",
rulesetId: "d6"
});
const character = await postJson(context.request, "/api/characters", {
name: "Auto Hero",
campaignId: campaign.id
});
await postJson(context.request, `/api/characters/${character.id}/skills`, {
name: "Stealth",
diceRollDefinition: "2D+1",
wildDice: 1,
allowFumble: true
});
await page.goto("/");
await expect(page.getByText("Campaign Log")).toBeVisible();
await page.getByRole("button", { name: "Roll Stealth" }).click();
const expandedEntry = page.locator(".log-panel .log-entry.expanded").first();
await expect(expandedEntry).toBeVisible();
await expect(expandedEntry.locator(".log-detail .roll-dice-strip")).toBeVisible();
});
test("Rolemaster UI exposes conditional create and edit fields", async ({ page, context }) => { test("Rolemaster UI exposes conditional create and edit fields", async ({ page, context }) => {
const username = `rm-ui-${Date.now()}`; const username = `rm-ui-${Date.now()}`;
await registerAndLogin(context.request, username, "Rolemaster UI"); await registerAndLogin(context.request, username, "Rolemaster UI");