Improve campaign log roll cards
This commit is contained in:
@@ -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.
|
||||
- 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`.
|
||||
- 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.
|
||||
- OpenAPI contract source remains at `openapi/RpgRoller.json`.
|
||||
|
||||
|
||||
@@ -34,7 +34,9 @@ public sealed class RolemasterApiTests : ApiTestBase
|
||||
|
||||
Assert.Equal(2, logPage.Entries.Length);
|
||||
Assert.Equal("8 + 6 | rolemaster", logPage.Entries[0].SummaryText);
|
||||
Assert.Null(logPage.Entries[0].EventBadges);
|
||||
Assert.Equal("74 | rolemaster", logPage.Entries[1].SummaryText);
|
||||
Assert.Null(logPage.Entries[1].EventBadges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -56,7 +58,13 @@ public sealed class RolemasterApiTests : ApiTestBase
|
||||
|
||||
Assert.Equal(-124, roll.Result);
|
||||
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.Collection(
|
||||
detail.Dice,
|
||||
|
||||
@@ -56,6 +56,7 @@ public sealed class ServiceRolemasterRollTests
|
||||
Assert.Equal(58, roll.Result);
|
||||
Assert.Equal("73-15=58", roll.Breakdown);
|
||||
Assert.Equal("73 | rolemaster", Assert.Single(logPage.Entries).SummaryText);
|
||||
Assert.Null(Assert.Single(logPage.Entries).EventBadges);
|
||||
|
||||
var die = Assert.Single(roll.Dice);
|
||||
Assert.Equal(73, die.Roll);
|
||||
@@ -83,6 +84,7 @@ public sealed class ServiceRolemasterRollTests
|
||||
Assert.Equal(323, roll.Result);
|
||||
Assert.Equal("97+96+45+85=323", roll.Breakdown);
|
||||
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.Collection(
|
||||
detail.Dice,
|
||||
@@ -129,7 +131,13 @@ public sealed class ServiceRolemasterRollTests
|
||||
|
||||
Assert.Equal(-124, roll.Result);
|
||||
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(
|
||||
roll.Dice,
|
||||
die =>
|
||||
@@ -161,4 +169,24 @@ public sealed class ServiceRolemasterRollTests
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,44 @@ public sealed class ServiceSkillRollTests
|
||||
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]
|
||||
public void RollDetail_ReturnsVisibleDetailAndHidesPrivateRoll()
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
IsCampaignDataLoading="IsCampaignDataLoading"
|
||||
CampaignLog="PlayVisibleCampaignLog"
|
||||
ExpandedRollId="ExpandedCampaignLogRollId"
|
||||
FreshRollId="FreshCampaignLogRollId"
|
||||
ToggleRollDetailRequested="ToggleRollDetailAsync"
|
||||
ResolveRollDetail="ResolveRollDetail"
|
||||
IsRollDetailLoading="IsRollDetailLoading"
|
||||
|
||||
@@ -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; } = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -58,6 +58,7 @@ test("Rolemaster open-ended roll detail renders specialized dice chips", async (
|
||||
|
||||
await page.goto("/");
|
||||
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();
|
||||
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();
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
const username = `rm-ui-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Rolemaster UI");
|
||||
|
||||
Reference in New Issue
Block a user