Refactor Home UI controls and add dice to campaign log entries

This commit is contained in:
2026-02-26 09:22:29 +01:00
parent 96238a9341
commit 2d1bf9b9b7
20 changed files with 774 additions and 255 deletions

View File

@@ -1,7 +1,8 @@
@page "/"
@implements IAsyncDisposable
@using RpgRoller.Components.Pages.HomeControls
<div class="rr-app">
<div class="@AppCssClass">
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
@if (!IsInitialized)
@@ -122,130 +123,39 @@
@if (CurrentScreen == "play")
{
<main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")">
<section class="card character-panel">
<div class="section-head"><h2>Character Context</h2></div>
@if (IsCampaignDataLoading)
{
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line short"></div><div class="skeleton-line"></div></div>
}
else if (SelectedCampaign is null)
{
<p class="empty">No campaign selected. Choose one in Campaign Management.</p>
}
else if (SelectedCampaign.Characters.Count == 0)
{
<p class="empty">No characters in this campaign yet.</p>
}
else
{
<div class="character-picker" role="tablist" aria-label="Character picker">
@foreach (var character in SelectedCampaign.Characters)
{
var isSelectedCharacter = SelectedCharacterId == character.Id;
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)" aria-label="@character.Name" @onclick="() => SelectCharacter(character.Id)">
<span class="icon-tab-glyph">@InitialsFor(character.Name)</span>
<span class="icon-tab-text">@character.Name</span>
</button>
}
</div>
@if (SelectedCharacter is not null)
{
<article class="character-sheet">
<h3>@SelectedCharacter.Name</h3>
<p>Owner: @OwnerLabel(SelectedCharacter.OwnerUserId)</p>
<p>Campaign: @SelectedCampaign.Name</p>
@if (SelectedCharacter.Id == ActiveCharacterId)
{
<span class="badge active">Active</span>
}
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="() => OpenEditCharacterModal(SelectedCharacter)">Edit Character</button>
<button type="button" disabled="@(IsMutating || !CanActivateCharacter(SelectedCharacter))" @onclick="() => ActivateCharacterAsync(SelectedCharacter.Id)">Activate Character</button>
</div>
</article>
<article class="skills-section">
<div class="section-head">
<h3>Skills</h3>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="OpenCreateSkillModal">Create Skill</button>
<button type="button" disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" @onclick="OpenEditSkillModal">Edit Skill</button>
</div>
</div>
@if (SelectedCharacterSkills.Count == 0)
{
<p class="empty">No skills for this character yet.</p>
}
else
{
<div class="skill-list">
@foreach (var skill in SelectedCharacterSkills)
{
var isSelectedSkill = SelectedSkillId == skill.Id;
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)" @onclick="() => SelectSkill(skill.Id)">
<strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span>
</button>
}
</div>
}
<form class="roll-panel" @onsubmit="RollSelectedSkillAsync" @onsubmit:preventDefault>
<label for="roll-visibility">Visibility</label>
<select id="roll-visibility" @bind="RollVisibility">
<option value="public">Public</option>
<option value="private">Private</option>
</select>
<button type="submit" disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill</button>
</form>
</article>
}
}
<article class="last-roll">
<h3>Last Roll</h3>
@if (LastRoll is null)
{
<p class="empty">No roll yet.</p>
}
else
{
<p class="roll-total">@LastRoll.Result</p>
@if (LastRoll.Dice.Count > 0)
{
<div class="roll-dice-strip" aria-label="Rolled dice">
@foreach (var die in LastRoll.Dice)
{
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieGlyph(die.Roll)</span>
}
</div>
}
<p>@LastRoll.Breakdown</p>
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p>
}
</article>
</section>
<CharacterPanel
IsCampaignDataLoading="IsCampaignDataLoading"
SelectedCampaign="SelectedCampaign"
SelectedCharacterId="SelectedCharacterId"
SelectedCharacter="SelectedCharacter"
IsMutating="IsMutating"
SelectedCharacterSkills="SelectedCharacterSkills"
SelectedSkillId="SelectedSkillId"
SelectedSkill="SelectedSkill"
RollVisibility="RollVisibility"
RollVisibilityChanged="OnRollVisibilityChanged"
LastRoll="LastRoll"
OwnerLabel="OwnerLabel"
SkillDefinitionLabel="SkillDefinitionLabel"
CanEditCharacter="CanEditCharacter"
CanEditSkill="CanEditSkill"
CanRollSkill="CanRollSkill"
CharacterSelected="SelectCharacterAsync"
SkillSelected="SelectSkill"
EditCharacterRequested="OpenEditCharacterModal"
CreateSkillRequested="OpenCreateSkillModal"
EditSkillRequested="OpenEditSkillModal"
RollRequested="RollSelectedSkillAsync" />
<aside class="card log-panel">
<div class="section-head"><h2>Campaign Log</h2></div>
@if (IsCampaignDataLoading)
{
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
}
else if (CampaignLog.Count == 0)
{
<p class="empty">No log entries yet.</p>
}
else
{
<ul class="log-list">
@foreach (var entry in CampaignLog)
{
<li class="log-entry @LogEntryCssClass(entry)">
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@SkillLabel(entry.SkillId)</strong> with <strong>@CharacterLabel(entry.CharacterId)</strong></p>
<p>@entry.Breakdown</p>
<p class="log-meta"><span class="badge @VisibilityBadgeCssClass(entry)">@VisibilityLabel(entry)</span> <time title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time></p>
</li>
}
</ul>
}
</aside>
<CampaignLogPanel
IsCampaignDataLoading="IsCampaignDataLoading"
CampaignLog="CampaignLog"
RollerLabel="RollerLabel"
SkillLabel="SkillLabel"
CharacterLabel="CharacterLabel"
LogEntryCssClass="LogEntryCssClass"
VisibilityLabel="VisibilityLabel"
VisibilityBadgeCssClass="VisibilityBadgeCssClass" />
</main>
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)" @onclick="SetMobilePanelCharacterAsync">Character</button>
@@ -337,7 +247,6 @@
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(character))" @onclick="() => OpenEditCharacterModal(character)">Edit</button>
<button type="button" disabled="@(IsMutating || !CanActivateCharacter(character))" @onclick="() => ActivateCharacterAsync(character.Id)">Activate</button>
</div>
</li>
}

View File

@@ -78,7 +78,7 @@ public partial class Home
private string? SelectedCampaignName => SelectedCampaign?.Name;
private CharacterSummary? SelectedCharacter => SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId);
private SkillSummary? SelectedSkill => SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
private string? ActiveCharacterName => SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == ActiveCharacterId)?.Name;
private string? ActiveCharacterName => SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId)?.Name;
private bool IsCurrentUserGm => SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
private bool IsSelectedCampaignD6 => string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
@@ -103,6 +103,8 @@ public partial class Home
_ => "offline"
};
private string AppCssClass => User is not null && CurrentScreen == "play" ? "rr-app app-play" : "rr-app";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
HasInteractiveRenderStarted = true;
@@ -264,6 +266,7 @@ public partial class Home
CampaignLog = (await RequestAsync<IReadOnlyList<CampaignLogEntry>>("GET", $"/api/campaigns/{SelectedCampaignId.Value}/log")).ToList();
SyncSelectedCharacter();
SyncSelectedSkill();
await EnsureSelectedCharacterActiveAsync();
}
catch (ApiRequestException ex) when (ex.StatusCode == 401)
{
@@ -619,27 +622,6 @@ public partial class Home
}
}
private async Task ActivateCharacterAsync(Guid characterId)
{
IsMutating = true;
try
{
await RequestWithoutPayloadAsync("POST", $"/api/characters/{characterId}/activate");
ActiveCharacterId = characterId;
SelectedCharacterId = characterId;
await RefreshCampaignScopeAsync();
SetStatus("Active character updated.", false);
}
catch (ApiRequestException ex)
{
SetStatus(ex.Message, true);
}
finally
{
IsMutating = false;
}
}
private void OpenCreateSkillModal()
{
SkillForm.Name = string.Empty;
@@ -808,10 +790,17 @@ public partial class Home
}
}
private void SelectCharacter(Guid characterId)
private Task OnRollVisibilityChanged(string visibility)
{
RollVisibility = string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
return Task.CompletedTask;
}
private async Task SelectCharacterAsync(Guid characterId)
{
SelectedCharacterId = characterId;
SyncSelectedSkill();
await EnsureSelectedCharacterActiveAsync();
}
private void SelectSkill(Guid skillId)
@@ -829,6 +818,35 @@ public partial class Home
return User is not null && character.OwnerUserId == User.Id;
}
private async Task EnsureSelectedCharacterActiveAsync()
{
if (!SelectedCharacterId.HasValue || SelectedCampaign is null)
{
return;
}
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value);
if (character is null || !CanActivateCharacter(character))
{
return;
}
if (ActiveCharacterId == character.Id)
{
return;
}
try
{
await RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate");
ActiveCharacterId = character.Id;
}
catch (ApiRequestException ex)
{
SetStatus(ex.Message, true);
}
}
private bool CanEditSkill(SkillSummary skill)
{
if (SelectedCampaign is null)
@@ -1033,82 +1051,6 @@ public partial class Home
return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumble}";
}
private static string RollDieGlyph(int roll)
{
return roll switch
{
1 => "\u2680",
2 => "\u2681",
3 => "\u2682",
4 => "\u2683",
5 => "\u2684",
6 => "\u2685",
_ => roll.ToString()
};
}
private static string RollDieCssClass(RollDieResult die)
{
var classes = new List<string> { "die-chip" };
if (die.Wild)
{
classes.Add("wild");
}
if (die.Crit)
{
classes.Add("crit");
}
if (die.Fumble)
{
classes.Add("fumble");
}
if (die.Removed)
{
classes.Add("removed");
}
if (die.Added)
{
classes.Add("added");
}
return string.Join(" ", classes);
}
private static string RollDieTitle(RollDieResult die)
{
var labels = new List<string> { $"Roll {die.Roll}" };
if (die.Wild)
{
labels.Add("wild");
}
if (die.Crit)
{
labels.Add("critical");
}
if (die.Fumble)
{
labels.Add("fumble");
}
if (die.Removed)
{
labels.Add("removed");
}
if (die.Added)
{
labels.Add("added");
}
return string.Join(", ", labels);
}
private string RollerLabel(CampaignLogEntry entry)
{
if (User is not null && entry.RollerUserId == User.Id)
@@ -1184,22 +1126,6 @@ public partial class Home
return "private-generic";
}
private static string InitialsFor(string value)
{
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length == 0)
{
return "?";
}
if (words.Length == 1)
{
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
}
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
}
private void ClearAuthenticatedState()
{
User = null;

View File

@@ -0,0 +1,56 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Contracts
@attribute [ExcludeFromCodeCoverage]
<aside class="card log-panel">
<div class="section-head"><h2>Campaign Log</h2></div>
@if (IsCampaignDataLoading)
{
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
}
else if (CampaignLog.Count == 0)
{
<p class="empty">No log entries yet.</p>
}
else
{
<ul class="log-list">
@foreach (var entry in CampaignLog)
{
<li class="log-entry @LogEntryCssClass(entry)">
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@SkillLabel(entry.SkillId)</strong> with <strong>@CharacterLabel(entry.CharacterId)</strong></p>
<p class="roll-total inline">@entry.Result</p>
<RollDiceStrip Dice="entry.Dice" AriaLabel="Log roll dice" />
<p>@entry.Breakdown</p>
<p class="log-meta"><span class="badge @VisibilityBadgeCssClass(entry)">@VisibilityLabel(entry)</span> <time title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time></p>
</li>
}
</ul>
}
</aside>
@code {
[Parameter]
public bool IsCampaignDataLoading { get; set; }
[Parameter]
public IReadOnlyList<CampaignLogEntry> CampaignLog { get; set; } = [];
[Parameter]
public Func<CampaignLogEntry, string> RollerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<Guid, string> SkillLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<Guid, string> CharacterLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CampaignLogEntry, string> LogEntryCssClass { get; set; } = _ => string.Empty;
[Parameter]
public Func<CampaignLogEntry, string> VisibilityLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CampaignLogEntry, string> VisibilityBadgeCssClass { get; set; } = _ => string.Empty;
}

View File

@@ -0,0 +1,186 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Contracts
@attribute [ExcludeFromCodeCoverage]
<section class="card character-panel">
<div class="section-head"><h2>Character Context</h2></div>
@if (IsCampaignDataLoading)
{
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line short"></div><div class="skeleton-line"></div></div>
}
else if (SelectedCampaign is null)
{
<p class="empty">No campaign selected. Choose one in Campaign Management.</p>
}
else if (SelectedCampaign.Characters.Count == 0)
{
<p class="empty">No characters in this campaign yet.</p>
}
else
{
<div class="character-picker" role="tablist" aria-label="Character picker">
@foreach (var character in SelectedCampaign.Characters)
{
var isSelectedCharacter = SelectedCharacterId == character.Id;
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)" aria-label="@character.Name" @onclick="() => CharacterSelected.InvokeAsync(character.Id)">
<span class="icon-tab-glyph">@InitialsFor(character.Name)</span>
<span class="icon-tab-text">@character.Name</span>
</button>
}
</div>
@if (SelectedCharacter is not null)
{
<article class="character-sheet">
<h3>@SelectedCharacter.Name</h3>
<p>Owner: @OwnerLabel(SelectedCharacter.OwnerUserId)</p>
<p>Campaign: @SelectedCampaign.Name</p>
<span class="badge active">Active</span>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="() => EditCharacterRequested.InvokeAsync(SelectedCharacter)">Edit Character</button>
</div>
</article>
<article class="skills-section">
<div class="section-head">
<h3>Skills</h3>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="CreateSkillRequested">Create Skill</button>
<button type="button" disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" @onclick="EditSkillRequested">Edit Skill</button>
</div>
</div>
@if (SelectedCharacterSkills.Count == 0)
{
<p class="empty">No skills for this character yet.</p>
}
else
{
<div class="skill-list">
@foreach (var skill in SelectedCharacterSkills)
{
var isSelectedSkill = SelectedSkillId == skill.Id;
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)" @onclick="() => SkillSelected.InvokeAsync(skill.Id)">
<strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span>
</button>
}
</div>
}
<form class="roll-panel" @onsubmit="OnRollSubmitAsync" @onsubmit:preventDefault>
<label for="roll-visibility">Visibility</label>
<select id="roll-visibility" value="@RollVisibility" @onchange="OnRollVisibilityChangedAsync">
<option value="public">Public</option>
<option value="private">Private</option>
</select>
<button type="submit" disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill</button>
</form>
</article>
}
}
<article class="last-roll">
<h3>Last Roll</h3>
@if (LastRoll is null)
{
<p class="empty">No roll yet.</p>
}
else
{
<p class="roll-total">@LastRoll.Result</p>
<RollDiceStrip Dice="LastRoll.Dice" AriaLabel="Last roll dice" />
<p>@LastRoll.Breakdown</p>
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p>
}
</article>
</section>
@code {
[Parameter]
public bool IsCampaignDataLoading { get; set; }
[Parameter]
public CampaignDetails? SelectedCampaign { get; set; }
[Parameter]
public Guid? SelectedCharacterId { get; set; }
[Parameter]
public CharacterSummary? SelectedCharacter { get; set; }
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public IReadOnlyList<SkillSummary> SelectedCharacterSkills { get; set; } = [];
[Parameter]
public Guid? SelectedSkillId { get; set; }
[Parameter]
public SkillSummary? SelectedSkill { get; set; }
[Parameter]
public string RollVisibility { get; set; } = "public";
[Parameter]
public EventCallback<string> RollVisibilityChanged { get; set; }
[Parameter]
public RollResult? LastRoll { get; set; }
[Parameter]
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<SkillSummary, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter]
public Func<SkillSummary, bool> CanEditSkill { get; set; } = _ => false;
[Parameter]
public Func<SkillSummary, bool> CanRollSkill { get; set; } = _ => false;
[Parameter]
public EventCallback<Guid> CharacterSelected { get; set; }
[Parameter]
public EventCallback<Guid> SkillSelected { get; set; }
[Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
[Parameter]
public EventCallback CreateSkillRequested { get; set; }
[Parameter]
public EventCallback EditSkillRequested { get; set; }
[Parameter]
public EventCallback RollRequested { get; set; }
private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args)
{
var selectedVisibility = args.Value?.ToString() ?? "public";
await RollVisibilityChanged.InvokeAsync(selectedVisibility);
}
private async Task OnRollSubmitAsync()
{
await RollRequested.InvokeAsync();
}
private static string InitialsFor(string value)
{
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length == 0)
{
return "?";
}
if (words.Length == 1)
{
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
}
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
}
}

View File

@@ -0,0 +1,97 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Contracts
@attribute [ExcludeFromCodeCoverage]
@if (Dice.Count > 0)
{
<div class="roll-dice-strip" aria-label="@AriaLabel">
@foreach (var die in Dice)
{
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieGlyph(die.Roll)</span>
}
</div>
}
@code {
[Parameter]
public IReadOnlyList<RollDieResult> Dice { get; set; } = [];
[Parameter]
public string AriaLabel { get; set; } = "Rolled dice";
private static string RollDieGlyph(int roll)
{
return roll switch
{
1 => "\u2680",
2 => "\u2681",
3 => "\u2682",
4 => "\u2683",
5 => "\u2684",
6 => "\u2685",
_ => roll.ToString()
};
}
private static string RollDieCssClass(RollDieResult die)
{
var classes = new List<string> { "die-chip" };
if (die.Wild)
{
classes.Add("wild");
}
if (die.Crit)
{
classes.Add("crit");
}
if (die.Fumble)
{
classes.Add("fumble");
}
if (die.Removed)
{
classes.Add("removed");
}
if (die.Added)
{
classes.Add("added");
}
return string.Join(" ", classes);
}
private static string RollDieTitle(RollDieResult die)
{
var labels = new List<string> { $"Roll {die.Roll}" };
if (die.Wild)
{
labels.Add("wild");
}
if (die.Crit)
{
labels.Add("critical");
}
if (die.Fumble)
{
labels.Add("fumble");
}
if (die.Removed)
{
labels.Add("removed");
}
if (die.Added)
{
labels.Add("added");
}
return string.Join(", ", labels);
}
}

View File

@@ -52,4 +52,5 @@ public sealed record CampaignLogEntry(
string Visibility,
int Result,
string Breakdown,
IReadOnlyList<RollDieResult> Dice,
DateTimeOffset TimestampUtc);

View File

@@ -69,6 +69,7 @@ public sealed class RpgRollerDbContext : DbContext
entity.HasKey(x => x.Id);
entity.Property(x => x.Visibility).HasConversion<string>().IsRequired();
entity.Property(x => x.Breakdown).IsRequired().HasMaxLength(256);
entity.Property(x => x.Dice).IsRequired();
entity.Property(x => x.TimestampUtc).IsRequired();
entity.HasIndex(x => x.CampaignId);
entity.HasIndex(x => x.RollerUserId);

View File

@@ -66,6 +66,7 @@ public sealed class RollLogEntry
public required RollVisibility Visibility { get; init; }
public required int Result { get; init; }
public required string Breakdown { get; init; }
public required string Dice { get; init; }
public required DateTimeOffset TimestampUtc { get; init; }
}

View File

@@ -0,0 +1,216 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RpgRoller.Data;
#nullable disable
namespace RpgRoller.Migrations
{
[DbContext(typeof(RpgRollerDbContext))]
[Migration("20260226100000_AddRollLogDice")]
partial class AddRollLogDice
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GmUserId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Ruleset")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GmUserId");
b.ToTable("Campaigns");
});
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("OwnerUserId");
b.ToTable("Characters");
});
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Breakdown")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Dice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Result")
.HasColumnType("INTEGER");
b.Property<Guid>("RollerUserId")
.HasColumnType("TEXT");
b.Property<Guid>("SkillId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("Visibility")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("CharacterId");
b.HasIndex("RollerUserId");
b.HasIndex("SkillId");
b.ToTable("RollLogEntries");
});
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.ToTable("Skills");
});
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ActiveCharacterId")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("UsernameNormalized")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UsernameNormalized")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
{
b.Property<string>("Token")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Token");
b.HasIndex("UserId");
b.ToTable("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RpgRoller.Migrations
{
/// <inheritdoc />
public partial class AddRollLogDice : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Dice",
table: "RollLogEntries",
type: "TEXT",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Dice",
table: "RollLogEntries");
}
}
}

View File

@@ -88,6 +88,10 @@ namespace RpgRoller.Migrations
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Dice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Result")
.HasColumnType("INTEGER");

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using RpgRoller.Contracts;
using RpgRoller.Data;
using RpgRoller.Domain;
@@ -8,6 +9,7 @@ namespace RpgRoller.Services;
public sealed class GameService : IGameService
{
private static readonly JsonSerializerOptions DiceJsonOptions = new(JsonSerializerDefaults.Web);
private readonly object m_Gate = new();
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
@@ -550,6 +552,7 @@ public sealed class GameService : IGameService
Visibility = parsedVisibility.Value,
Result = roll.Total,
Breakdown = roll.Breakdown,
Dice = SerializeDice(roll.Dice),
TimestampUtc = DateTimeOffset.UtcNow
};
@@ -810,6 +813,8 @@ public sealed class GameService : IGameService
private static CampaignLogEntry ToLogEntry(RollLogEntry entry)
{
var dice = DeserializeDice(entry.Dice);
return new CampaignLogEntry(
entry.Id,
entry.CampaignId,
@@ -819,9 +824,32 @@ public sealed class GameService : IGameService
entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result,
entry.Breakdown,
dice,
entry.TimestampUtc);
}
private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
{
return JsonSerializer.Serialize(dice, DiceJsonOptions);
}
private static IReadOnlyList<RollDieResult> DeserializeDice(string serializedDice)
{
if (string.IsNullOrWhiteSpace(serializedDice))
{
return [];
}
try
{
return JsonSerializer.Deserialize<IReadOnlyList<RollDieResult>>(serializedDice, DiceJsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
private bool CanEditCharacterLocked(Guid actorUserId, Character character, Campaign campaign)
{
return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId;
@@ -1063,6 +1091,7 @@ public sealed class GameService : IGameService
Visibility = entry.Visibility,
Result = entry.Result,
Breakdown = entry.Breakdown,
Dice = entry.Dice,
TimestampUtc = entry.TimestampUtc
};
}

View File

@@ -23,6 +23,7 @@ html,
body {
margin: 0;
min-height: 100%;
height: 100%;
}
body {
@@ -47,6 +48,12 @@ h3 {
padding: 1rem 1rem 4.5rem;
}
.rr-app.app-play {
height: 100dvh;
overflow: hidden;
padding-bottom: 1rem;
}
.loading-shell,
.auth-shell {
display: grid;
@@ -65,6 +72,12 @@ h3 {
gap: 1rem;
}
.app-play .workspace-shell {
height: 100%;
min-height: 0;
grid-template-rows: auto auto minmax(0, 1fr);
}
.workspace-header {
position: sticky;
top: 0;
@@ -212,6 +225,11 @@ select:focus-visible {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(19rem, 1fr);
gap: 1rem;
min-height: 0;
}
.app-play .play-screen {
height: 100%;
}
.management-screen {
@@ -266,6 +284,19 @@ select:focus-visible {
gap: 0.4rem;
}
.app-play .character-panel {
overflow: hidden;
}
.log-panel {
min-height: 0;
}
.app-play .log-panel {
overflow-y: auto;
overscroll-behavior: contain;
}
.skill-list {
display: grid;
gap: 0.35rem;
@@ -297,6 +328,10 @@ select:focus-visible {
margin: 0;
}
.roll-total.inline {
font-size: 1.2rem;
}
.roll-dice-strip {
display: flex;
flex-wrap: wrap;
@@ -564,4 +599,8 @@ select:focus-visible {
.mobile-bottom-nav {
display: flex;
}
.rr-app.app-play {
padding-bottom: 4.25rem;
}
}