Simplify workspace composition root

This commit is contained in:
2026-04-05 01:19:12 +02:00
parent 6cdd29ed93
commit b291d0531f
6 changed files with 319 additions and 394 deletions

View File

@@ -38,8 +38,8 @@ Frontend:
- `RpgRoller/Components/Pages/Home.razor`: gateway that switches between loading, auth, and authenticated workspace views
- `RpgRoller/Components/Pages/Home.razor.cs`: gateway/session orchestration for `Home`
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI
- `RpgRoller/Components/Pages/Workspace.razor.cs`: workspace composition root and JS-invokable entry points
- `RpgRoller/Components/Pages/WorkspaceState.cs`: workspace UI state and computed projections
- `RpgRoller/Components/Pages/Workspace.razor.cs`: workspace composition root, coordinator wiring, lifecycle, and JS-invokable entry points
- `RpgRoller/Components/Pages/WorkspaceState.cs`: workspace UI state plus pure computed and formatting projections used directly by the Razor view
- `RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, `WorkspaceCampaignScopeCoordinator.cs`, `WorkspacePlayCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceLiveStateController.cs`, `WorkspaceFeedbackService.cs`, and `WorkspaceToast.cs`: session/bootstrap, campaign scope, play/log, admin, live update, and toast concerns used by `Workspace`
- `RpgRoller/Components/Pages/HomeControls/`: workspace and auth child components, forms, header, panels, and modal controls
- `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions
@@ -49,7 +49,7 @@ Frontend:
Current repo note:
- `TASKS.md` tracks the remaining Workspace cleanup work.
- `TASKS.md` records the completed decomposition work and the final execution notes for this refactor.
- This README describes the code as it exists today. It does not treat blueprint items in `TASKS.md` as finished unless they are already present in the repo.
## Runtime and Persistence

View File

@@ -0,0 +1,116 @@
using RpgRoller.Components.Pages;
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Tests;
public sealed class WorkspaceStateTests
{
[Fact]
public void OwnerLabel_ResolvesCurrentUserGmAndFallbacks()
{
var gmId = Guid.NewGuid();
var userId = Guid.NewGuid();
var otherOwnerId = Guid.NewGuid();
var state = new WorkspaceState
{
User = new UserSummary(userId, "user", "User", []),
SelectedCampaign = new CampaignRoster(
Guid.NewGuid(),
"Alpha",
"d6",
new CampaignGmSummary(gmId, "GM"),
[
new CharacterSummary(Guid.NewGuid(), "Scout", otherOwnerId, Guid.NewGuid(), "Other Owner")
])
};
Assert.Equal("You", state.OwnerLabel(userId));
Assert.Equal("GM (GM)", state.OwnerLabel(gmId));
Assert.Equal("Other Owner", state.OwnerLabel(otherOwnerId));
Assert.Equal("Unknown owner", state.OwnerLabel(Guid.NewGuid()));
}
[Fact]
public void SkillDefinitionLabel_FormatsD6RolemasterAndDefaultRulesets()
{
var skill = new CharacterSheetSkill(Guid.NewGuid(), null, "Awareness", "d100!+15", 1, true, 5);
var state = new WorkspaceState
{
SelectedCampaign = new CampaignRoster(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(Guid.NewGuid(), "GM"), [])
};
Assert.Equal("d100!+15, wild 1, fumble on", state.SkillDefinitionLabel(skill));
state.SelectedCampaign = new CampaignRoster(Guid.NewGuid(), "Alpha", "rolemaster", new CampaignGmSummary(Guid.NewGuid(), "GM"), []);
Assert.Equal("Open-ended percentile: d100!+15, fumble <= 5", state.SkillDefinitionLabel(skill));
state.SelectedCampaign = new CampaignRoster(Guid.NewGuid(), "Alpha", "dnd5e", new CampaignGmSummary(Guid.NewGuid(), "GM"), []);
Assert.Equal("d100!+15", state.SkillDefinitionLabel(skill));
}
[Fact]
public void PlaySelections_FilterToOwnedCharactersAndPreferSelectedThenActive()
{
var userId = Guid.NewGuid();
var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", userId, Guid.NewGuid(), "User");
var secondOwnedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned Two", userId, Guid.NewGuid(), "User");
var otherCharacter = new CharacterSummary(Guid.NewGuid(), "Other", Guid.NewGuid(), Guid.NewGuid(), "Other");
var state = new WorkspaceState
{
User = new UserSummary(userId, "user", "User", []),
SelectedCampaign = new CampaignRoster(
Guid.NewGuid(),
"Alpha",
"d6",
new CampaignGmSummary(Guid.NewGuid(), "GM"),
[ownedCharacter, secondOwnedCharacter, otherCharacter]),
SelectedCharacterId = secondOwnedCharacter.Id,
ActiveCharacterId = ownedCharacter.Id,
SelectedCharacterSkills = [new CharacterSheetSkill(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null)],
SelectedCharacterSkillGroups = [new CharacterSheetSkillGroup(Guid.NewGuid(), "Combat", "2D+1", 1, true, null)]
};
Assert.Equal(2, state.PlaySelectedCampaign!.Characters.Length);
Assert.Equal(secondOwnedCharacter.Id, state.PlaySelectedCharacterId);
Assert.Single(state.PlaySelectedCharacterSkills);
Assert.Single(state.PlaySelectedCharacterSkillGroups);
state.SelectedCharacterId = Guid.NewGuid();
Assert.Equal(ownedCharacter.Id, state.PlaySelectedCharacterId);
state.ActiveCharacterId = Guid.NewGuid();
Assert.Equal(ownedCharacter.Id, state.PlaySelectedCharacterId);
}
[Fact]
public void ScreenAndConnectionFlags_ReflectCurrentState()
{
var adminId = Guid.NewGuid();
var state = new WorkspaceState
{
User = new UserSummary(adminId, "admin", "Admin", [UserRoles.Admin]),
SelectedCampaign = new CampaignRoster(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(adminId, "Admin"), []),
CurrentScreen = "admin",
ConnectionState = "reconnecting"
};
Assert.True(state.IsAdminScreen);
Assert.False(state.IsPlayScreen);
Assert.True(state.IsCurrentUserAdmin);
Assert.True(state.IsCurrentUserGm);
Assert.True(state.CanDeleteSelectedCampaign);
Assert.True(state.IsSelectedCampaignD6);
Assert.Equal("Reconnecting", state.ConnectionStateLabel);
Assert.Equal("warn", state.ConnectionStateCssClass);
Assert.Equal("rr-app", state.AppCssClass);
state.CurrentScreen = "play";
state.ConnectionState = "connected";
Assert.True(state.IsPlayScreen);
Assert.Equal("Connected", state.ConnectionStateLabel);
Assert.Equal("ok", state.ConnectionStateCssClass);
Assert.Equal("rr-app app-play", state.AppCssClass);
}
}

View File

@@ -1,111 +1,111 @@
@using RpgRoller.Components.Pages.HomeControls
<div class="@AppCssClass">
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
<div class="@State.AppCssClass">
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
@if (HasHealthIssue)
@if (State.HasHealthIssue)
{
<section class="health-banner" role="alert">
<div>
<strong>API currently unavailable.</strong>
<p>@HealthIssueMessage</p>
<p>@State.HealthIssueMessage</p>
</div>
<button type="button" @onclick="RetryAfterHealthIssueAsync">Retry</button>
<button type="button" @onclick="Session.RetryAfterHealthIssueAsync">Retry</button>
</section>
}
<div class="workspace-shell">
<AppHeader
User="User"
User="State.User"
ShowCampaign="true"
CampaignName="@SelectedCampaignName"
CampaignName="@State.SelectedCampaignName"
ShowConnectionState="true"
ConnectionStateLabel="@ConnectionStateLabel"
ConnectionStateCssClass="@ConnectionStateCssClass"
IsMenuOpen="IsScreenMenuOpen"
ConnectionStateLabel="@State.ConnectionStateLabel"
ConnectionStateCssClass="@State.ConnectionStateCssClass"
IsMenuOpen="State.IsScreenMenuOpen"
MenuButtonId="workspace-screen-menu-button"
MenuId="workspace-screen-menu"
MenuItems="HeaderMenuItems"
ToggleMenuRequested="ToggleScreenMenu"
LogoutRequested="LogoutAsync"/>
LogoutRequested="Session.LogoutAsync"/>
@if (IsPlayScreen)
@if (State.IsPlayScreen)
{
<main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")">
<main class="play-screen @(State.MobilePanel == "log" ? "mobile-log" : "mobile-character")">
<CharacterPanel
IsCampaignDataLoading="IsCampaignDataLoading"
SelectedCampaign="PlaySelectedCampaign"
SelectedCharacterId="PlaySelectedCharacterId"
SelectedCharacter="PlaySelectedCharacter"
IsMutating="IsMutating"
SelectedCharacterSkills="PlaySelectedCharacterSkills"
SelectedCharacterSkillGroups="PlaySelectedCharacterSkillGroups"
SelectedCampaignRulesetId="@(PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="RollVisibility"
RollVisibilityChanged="OnRollVisibilityChanged"
OwnerLabel="OwnerLabel"
SkillDefinitionLabel="SkillDefinitionLabel"
CanEditCharacter="CanEditCharacter"
CanEditSkill="CanEditSkill"
CharacterSelected="SelectCharacterAsync"
EditCharacterRequested="OpenEditCharacterModal"
SkillCreated="OnSkillCreatedAsync"
SkillUpdated="OnSkillUpdatedAsync"
SkillGroupCreated="OnSkillGroupCreatedAsync"
SkillGroupUpdated="OnSkillGroupUpdatedAsync"
SkillDeleted="OnSkillDeletedAsync"
SkillGroupDeleted="OnSkillGroupDeletedAsync"
ErrorOccurred="OnCharacterPanelErrorAsync"
RollRequested="RollSkillAsync"/>
IsCampaignDataLoading="State.IsCampaignDataLoading"
SelectedCampaign="State.PlaySelectedCampaign"
SelectedCharacterId="State.PlaySelectedCharacterId"
SelectedCharacter="State.PlaySelectedCharacter"
IsMutating="State.IsMutating"
SelectedCharacterSkills="State.PlaySelectedCharacterSkills"
SelectedCharacterSkillGroups="State.PlaySelectedCharacterSkillGroups"
SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="State.RollVisibility"
RollVisibilityChanged="Session.OnRollVisibilityChangedAsync"
OwnerLabel="State.OwnerLabel"
SkillDefinitionLabel="State.SkillDefinitionLabel"
CanEditCharacter="Campaigns.CanEditCharacter"
CanEditSkill="Play.CanEditSkill"
CharacterSelected="Play.SelectCharacterAsync"
EditCharacterRequested="Campaigns.OpenEditCharacterModal"
SkillCreated="Play.OnSkillCreatedAsync"
SkillUpdated="Play.OnSkillUpdatedAsync"
SkillGroupCreated="Play.OnSkillGroupCreatedAsync"
SkillGroupUpdated="Play.OnSkillGroupUpdatedAsync"
SkillDeleted="Play.OnSkillDeletedAsync"
SkillGroupDeleted="Play.OnSkillGroupDeletedAsync"
ErrorOccurred="Play.OnCharacterPanelErrorAsync"
RollRequested="Play.RollSkillAsync"/>
<CampaignLogPanel
IsCampaignDataLoading="IsCampaignDataLoading"
CampaignLog="PlayVisibleCampaignLog"
ExpandedRollId="ExpandedCampaignLogRollId"
FreshRollId="FreshCampaignLogRollId"
SelectedCharacterId="PlaySelectedCharacterId"
SelectedCharacterName="@(PlaySelectedCharacter?.Name)"
SelectedCampaignRulesetId="@(PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="RollVisibility"
IsMutating="IsMutating"
ToggleRollDetailRequested="ToggleRollDetailAsync"
ResolveRollDetail="ResolveRollDetail"
IsRollDetailLoading="IsRollDetailLoading"
GetRollDetailError="GetRollDetailError"
CustomRollCreated="OnCustomRollCreatedAsync"
ErrorOccurred="OnCampaignLogPanelErrorAsync"/>
IsCampaignDataLoading="State.IsCampaignDataLoading"
CampaignLog="State.PlayVisibleCampaignLog"
ExpandedRollId="State.ExpandedCampaignLogRollId"
FreshRollId="State.FreshCampaignLogRollId"
SelectedCharacterId="State.PlaySelectedCharacterId"
SelectedCharacterName="@(State.PlaySelectedCharacter?.Name)"
SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="State.RollVisibility"
IsMutating="State.IsMutating"
ToggleRollDetailRequested="Play.ToggleRollDetailAsync"
ResolveRollDetail="Play.ResolveRollDetail"
IsRollDetailLoading="Play.IsRollDetailLoading"
GetRollDetailError="Play.GetRollDetailError"
CustomRollCreated="Play.OnCustomRollCreatedAsync"
ErrorOccurred="Play.OnCampaignLogPanelErrorAsync"/>
</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 type="button" class="switch @(State.MobilePanel == "character" ? "active" : string.Empty)"
@onclick='() => Scope.SetMobilePanelAsync("character")'>Character
</button>
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)"
@onclick="SetMobilePanelLogAsync">Log
<button type="button" class="switch @(State.MobilePanel == "log" ? "active" : string.Empty)"
@onclick='() => Scope.SetMobilePanelAsync("log")'>Log
</button>
</nav>
}
else if (IsManagementScreen)
else if (State.IsManagementScreen)
{
<CampaignManagementPanel
Campaigns="Campaigns"
SelectedCampaignId="SelectedCampaignId"
SelectedCampaign="SelectedCampaign"
Rulesets="Rulesets"
IsMutating="IsMutating"
OwnerLabel="OwnerLabel"
CanEditCharacter="CanEditCharacter"
CanDeleteCharacter="CanDeleteCharacter"
CanDeleteCampaign="CanDeleteSelectedCampaign"
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
CampaignCreated="OnCampaignCreatedAsync"
DeleteCampaignRequested="DeleteSelectedCampaignAsync"
CreateCharacterRequested="OpenCreateCharacterModal"
EditCharacterRequested="OpenEditCharacterModal"
DeleteCharacterRequested="DeleteCharacterAsync"/>
Campaigns="State.Campaigns"
SelectedCampaignId="State.SelectedCampaignId"
SelectedCampaign="State.SelectedCampaign"
Rulesets="State.Rulesets"
IsMutating="State.IsMutating"
OwnerLabel="State.OwnerLabel"
CanEditCharacter="Campaigns.CanEditCharacter"
CanDeleteCharacter="Campaigns.CanDeleteCharacter"
CanDeleteCampaign="State.CanDeleteSelectedCampaign"
CampaignSelectionChanged="Campaigns.OnCampaignSelectionChangedAsync"
CampaignCreated="Campaigns.OnCampaignCreatedAsync"
DeleteCampaignRequested="Campaigns.DeleteSelectedCampaignAsync"
CreateCharacterRequested="Campaigns.OpenCreateCharacterModal"
EditCharacterRequested="Campaigns.OpenEditCharacterModal"
DeleteCharacterRequested="Campaigns.DeleteCharacterAsync"/>
}
else if (IsAdminScreen)
else if (State.IsAdminScreen)
{
<main class="management-screen">
@if (IsCurrentUserAdmin)
@if (State.IsCurrentUserAdmin)
{
<section class="card">
<div class="section-head">
@@ -121,22 +121,22 @@
<div class="section-head">
<h2>User Management</h2>
</div>
@if (IsAdminDataLoading)
@if (State.IsAdminDataLoading)
{
<p class="empty">Loading users...</p>
}
else if (!IsCurrentUserAdmin)
else if (!State.IsCurrentUserAdmin)
{
<p class="empty">Admin role is required to manage users.</p>
}
else if (AdminUsers.Count == 0)
else if (State.AdminUsers.Count == 0)
{
<p class="empty">No users found.</p>
}
else
{
<ul class="management-list">
@foreach (var user in AdminUsers)
@foreach (var user in State.AdminUsers)
{
<li>
<div>
@@ -147,15 +147,15 @@
<div class="skill-chip-actions">
<button type="button"
class="chip-button"
disabled="@(IsMutating || user.Id == User?.Id)"
@onclick="() => ToggleAdminRoleAsync(user)">
disabled="@(State.IsMutating || user.Id == State.User?.Id)"
@onclick="() => Admin.ToggleAdminRoleAsync(user)">
<span aria-hidden="true" class="emoji">🛡️</span>
<span class="sr-only">Toggle admin role for @user.Username</span>
</button>
<button type="button"
class="chip-button"
disabled="@(IsMutating || user.Id == User?.Id)"
@onclick="() => DeleteUserAsync(user)">
disabled="@(State.IsMutating || user.Id == State.User?.Id)"
@onclick="() => Admin.DeleteUserAsync(user)">
<span aria-hidden="true" class="emoji">🗑️</span>
<span class="sr-only">Delete user @user.Username</span>
</button>
@@ -169,10 +169,10 @@
}
</div>
@if (Toasts.Count > 0)
@if (State.Toasts.Count > 0)
{
<div class="toast-stack" aria-live="polite" aria-atomic="false">
@foreach (var toast in Toasts)
@foreach (var toast in State.Toasts)
{
<div class="toast @(toast.IsError ? "error" : "success")" role="status">
<p>@toast.Message</p>
@@ -183,35 +183,35 @@
</div>
<CharacterFormModal
Visible="ShowCreateCharacterModal"
Visible="State.ShowCreateCharacterModal"
Title="Create Character"
SubmitLabel="Create Character"
NameInputId="character-create-name"
CampaignInputId="character-create-campaign"
OwnerUsernameInputId="character-create-owner"
InitialModel="CreateCharacterInitialModel"
FormVersion="CreateCharacterFormVersion"
InitialModel="State.CreateCharacterInitialModel"
FormVersion="State.CreateCharacterFormVersion"
EditingCharacterId="null"
CampaignOptions="CharacterCampaignOptions"
IsMutating="IsMutating"
CampaignOptions="State.CharacterCampaignOptions"
IsMutating="State.IsMutating"
AllowOwnerEdit="false"
AvailableUsernames="KnownUsernames"
CharacterSaved="OnCharacterCreatedAsync"
CancelRequested="CloseCharacterModals"/>
AvailableUsernames="State.KnownUsernames"
CharacterSaved="Campaigns.OnCharacterCreatedAsync"
CancelRequested="Campaigns.CloseCharacterModals"/>
<CharacterFormModal
Visible="ShowEditCharacterModal"
Visible="State.ShowEditCharacterModal"
Title="Edit Character"
SubmitLabel="Save Character"
NameInputId="character-edit-name"
CampaignInputId="character-edit-campaign"
OwnerUsernameInputId="character-edit-owner"
InitialModel="EditCharacterInitialModel"
FormVersion="EditCharacterFormVersion"
EditingCharacterId="EditingCharacterId"
CampaignOptions="CharacterCampaignOptions"
IsMutating="IsMutating"
AllowOwnerEdit="CanEditCharacterOwner"
AvailableUsernames="KnownUsernames"
CharacterSaved="OnCharacterUpdatedAsync"
CancelRequested="CloseCharacterModals"/>
InitialModel="State.EditCharacterInitialModel"
FormVersion="State.EditCharacterFormVersion"
EditingCharacterId="State.EditingCharacterId"
CampaignOptions="State.CharacterCampaignOptions"
IsMutating="State.IsMutating"
AllowOwnerEdit="State.CanEditCharacterOwner"
AvailableUsernames="State.KnownUsernames"
CharacterSaved="Campaigns.OnCharacterUpdatedAsync"
CancelRequested="Campaigns.CloseCharacterModals"/>

View File

@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Components.Pages;
@@ -12,7 +11,7 @@ public partial class Workspace : IAsyncDisposable
{
protected override async Task OnAfterRenderAsync(bool firstRender)
{
HasInteractiveRenderStarted = true;
State.HasInteractiveRenderStarted = true;
if (!firstRender)
return;
@@ -20,133 +19,26 @@ public partial class Workspace : IAsyncDisposable
await InvokeAsync(StateHasChanged);
}
private Task RetryAfterHealthIssueAsync()
{
return Session.RetryAfterHealthIssueAsync();
}
private Task LoadKnownUsernamesAsync()
{
return Session.LoadKnownUsernamesAsync();
}
private Task ReloadCampaignsAsync(Guid? preferredCampaignId) => Scope.ReloadCampaignsAsync(preferredCampaignId);
private Task ReloadCharacterCampaignOptionsAsync() => Scope.ReloadCharacterCampaignOptionsAsync();
private Task RefreshCampaignRosterAsync() => Scope.RefreshCampaignRosterAsync();
private Task RefreshCampaignLogAsync(Guid? afterRollId = null) => Play.RefreshCampaignLogAsync(afterRollId);
private Task RefreshCampaignScopeAsync() => Scope.RefreshCampaignScopeAsync();
private Task LogoutAsync() => Session.LogoutAsync();
private Task SwitchScreenAsync(string screen) => Session.SwitchScreenAsync(screen);
private Task SwitchToPlayAsync()
{
return SwitchScreenAsync("play");
}
private Task SwitchToManagementAsync()
{
return SwitchScreenAsync("management");
}
private Task SwitchToAdminAsync()
{
return SwitchScreenAsync(ScreenAdmin);
}
private Task EnsureAdminUsersLoadedAsync() => Admin.EnsureAdminUsersLoadedAsync();
private Task ToggleAdminRoleAsync(AdminUserSummary user) => Admin.ToggleAdminRoleAsync(user);
private Task DeleteUserAsync(AdminUserSummary user) => Admin.DeleteUserAsync(user);
private Task SetMobilePanelAsync(string panel) => Scope.SetMobilePanelAsync(panel);
private Task SetMobilePanelCharacterAsync()
{
return SetMobilePanelAsync("character");
}
private Task SetMobilePanelLogAsync()
{
return SetMobilePanelAsync("log");
}
private Task OnCampaignSelectionChangedAsync(ChangeEventArgs args) => CampaignsFlow.OnCampaignSelectionChangedAsync(args);
private Task OnCampaignCreatedAsync(Guid campaignId) => CampaignsFlow.OnCampaignCreatedAsync(campaignId);
private void OpenCreateCharacterModal() => CampaignsFlow.OpenCreateCharacterModal();
private Task OpenEditCharacterModal(CharacterSummary character) => CampaignsFlow.OpenEditCharacterModal(character);
private void CloseCharacterModals() => CampaignsFlow.CloseCharacterModals();
private Task OnCharacterCreatedAsync(Guid? campaignId) => CampaignsFlow.OnCharacterCreatedAsync(campaignId);
private Task OnCharacterUpdatedAsync(Guid? campaignId) => CampaignsFlow.OnCharacterUpdatedAsync(campaignId);
private Task DeleteSelectedCampaignAsync() => CampaignsFlow.DeleteSelectedCampaignAsync();
private Task DeleteCharacterAsync(CharacterSummary character) => CampaignsFlow.DeleteCharacterAsync(character);
private Task SelectCharacterAsync(Guid characterId) => Play.SelectCharacterAsync(characterId);
private bool CanEditCharacter(CharacterSummary character) => CampaignsFlow.CanEditCharacter(character);
private bool CanDeleteCharacter(CharacterSummary character) => CampaignsFlow.CanDeleteCharacter(character);
private Task EnsureSelectedCharacterActiveAsync() => Play.EnsureSelectedCharacterActiveAsync();
private Task RefreshSelectedCharacterSheetAsync() => Play.RefreshSelectedCharacterSheetAsync();
private Task ToggleRollDetailAsync(Guid rollId) => Play.ToggleRollDetailAsync(rollId);
private Task OnSkillCreatedAsync(Guid id) => Play.OnSkillCreatedAsync(id);
private Task OnSkillUpdatedAsync(Guid id) => Play.OnSkillUpdatedAsync(id);
private Task OnSkillGroupCreatedAsync(Guid id) => Play.OnSkillGroupCreatedAsync(id);
private Task OnSkillGroupUpdatedAsync(Guid id) => Play.OnSkillGroupUpdatedAsync(id);
private Task OnSkillDeletedAsync(Guid id) => Play.OnSkillDeletedAsync(id);
private Task OnSkillGroupDeletedAsync(Guid id) => Play.OnSkillGroupDeletedAsync(id);
private Task OnCharacterPanelErrorAsync(string message) => Play.OnCharacterPanelErrorAsync(message);
private Task OnCampaignLogPanelErrorAsync(string message) => Play.OnCampaignLogPanelErrorAsync(message);
private Task RollSkillAsync(Guid skillId) => Play.RollSkillAsync(skillId);
private Task OnCustomRollCreatedAsync(RollResult roll) => Play.OnCustomRollCreatedAsync(roll);
private Task OnRollVisibilityChanged(string visibility) => Session.OnRollVisibilityChangedAsync(visibility);
private bool CanEditSkill(CharacterSheetSkill skill) => Play.CanEditSkill(skill);
[JSInvokable]
public Task OnStateEventReceived(CampaignStateSnapshot state) => Live.OnStateEventReceivedAsync(state);
[JSInvokable]
public Task OnConnectionStateChanged(string state) => Live.OnConnectionStateChangedAsync(state);
private Task SyncStateEventsAsync() => Live.SyncStateEventsAsync();
private Task StopStateEventsAsync() => Live.StopStateEventsAsync();
public async ValueTask DisposeAsync()
{
await StopStateEventsAsync();
DotNetRef?.Dispose();
}
private bool CanEditCharacter(CharacterSummary character) => Campaigns.CanEditCharacter(character);
private void ClearAuthenticatedState() => Session.ClearAuthenticatedState();
private Task EnsureAdminUsersLoadedAsync() => Admin.EnsureAdminUsersLoadedAsync();
private Task StopStateEventsAsync() => Live.StopStateEventsAsync();
private async Task StartStateEventsCoreAsync(Guid campaignId)
{
DotNetRef ??= DotNetObjectReference.Create(this);
@@ -167,71 +59,16 @@ public partial class Workspace : IAsyncDisposable
}
}
private void ToggleScreenMenu()
{
State.IsScreenMenuOpen = !State.IsScreenMenuOpen;
}
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
{
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
}
private string OwnerLabel(Guid ownerUserId)
{
if (User is not null && ownerUserId == User.Id)
return "You";
if (SelectedCampaign is null)
return "Unknown owner";
if (ownerUserId == SelectedCampaign.Gm.Id)
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
var ownerDisplayName = SelectedCampaign.Characters
.Where(character => character.OwnerUserId == ownerUserId)
.Select(character => character.OwnerDisplayName)
.FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
}
private string SkillDefinitionLabel(CharacterSheetSkill skill)
{
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange);
return skill.DiceRollDefinition;
}
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}";
}
private CampaignRollDetail? ResolveRollDetail(Guid rollId) => Play.ResolveRollDetail(rollId);
private bool IsRollDetailLoading(Guid rollId) => Play.IsRollDetailLoading(rollId);
private string? GetRollDetailError(Guid rollId) => Play.GetRollDetailError(rollId);
private void ResetCampaignLogDetailState() => Play.ResetCampaignLogDetailState();
private void ClearAuthenticatedState() => Session.ClearAuthenticatedState();
private void SetStatus(string message, bool isError)
{
Feedback.SetStatus(message, isError);
}
private void Announce(string message)
{
Feedback.Announce(message);
}
private void ToggleScreenMenu()
{
IsScreenMenuOpen = !IsScreenMenuOpen;
}
private void ResetCampaignStateTracking() => Play.ResetCampaignStateTracking();
[Inject]
private IJSRuntime JS { get; set; } = null!;
@@ -244,97 +81,35 @@ public partial class Workspace : IAsyncDisposable
[Inject]
private NavigationManager Navigation { get; set; } = null!;
private WorkspaceState State { get; } = new();
private UserSummary? User { get => State.User; set => State.User = value; }
private Guid? ActiveCharacterId { get => State.ActiveCharacterId; set => State.ActiveCharacterId = value; }
private Guid? SelectedCampaignId { get => State.SelectedCampaignId; set => State.SelectedCampaignId = value; }
private CampaignRoster? SelectedCampaign { get => State.SelectedCampaign; set => State.SelectedCampaign = value; }
private List<CampaignSummary> Campaigns { get => State.Campaigns; set => State.Campaigns = value; }
private List<CampaignOption> CharacterCampaignOptions { get => State.CharacterCampaignOptions; set => State.CharacterCampaignOptions = value; }
private List<CharacterSheetSkill> SelectedCharacterSkills { get => State.SelectedCharacterSkills; set => State.SelectedCharacterSkills = value; }
private List<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get => State.SelectedCharacterSkillGroups; set => State.SelectedCharacterSkillGroups = value; }
private List<CampaignLogListEntry> CampaignLog { get => State.CampaignLog; set => State.CampaignLog = value; }
private List<RulesetDefinition> Rulesets { get => State.Rulesets; set => State.Rulesets = value; }
private List<AdminUserSummary> AdminUsers { get => State.AdminUsers; set => State.AdminUsers = value; }
private Guid? SelectedCharacterId { get => State.SelectedCharacterId; set => State.SelectedCharacterId = value; }
private RollResult? LastRoll { get => State.LastRoll; set => State.LastRoll = value; }
private List<string> KnownUsernames { get => State.KnownUsernames; set => State.KnownUsernames = value; }
private string RollVisibility { get => State.RollVisibility; set => State.RollVisibility = value; }
private bool IsMutating { get => State.IsMutating; set => State.IsMutating = value; }
private bool IsCampaignDataLoading { get => State.IsCampaignDataLoading; set => State.IsCampaignDataLoading = value; }
private bool IsAdminDataLoading { get => State.IsAdminDataLoading; set => State.IsAdminDataLoading = value; }
private bool HasLoadedAdminUsers { get => State.HasLoadedAdminUsers; set => State.HasLoadedAdminUsers = value; }
private bool HasHealthIssue { get => State.HasHealthIssue; set => State.HasHealthIssue = value; }
private string HealthIssueMessage { get => State.HealthIssueMessage; set => State.HealthIssueMessage = value; }
private List<WorkspaceToast> Toasts => State.Toasts;
private string CurrentScreen { get => State.CurrentScreen; set => State.CurrentScreen = value; }
private string MobilePanel { get => State.MobilePanel; set => State.MobilePanel = value; }
private string ConnectionState { get => State.ConnectionState; set => State.ConnectionState = value; }
private string LiveAnnouncement { get => State.LiveAnnouncement; set => State.LiveAnnouncement = value; }
private bool IsScreenMenuOpen { get => State.IsScreenMenuOpen; set => State.IsScreenMenuOpen = value; }
private bool ShowCreateCharacterModal { get => State.ShowCreateCharacterModal; set => State.ShowCreateCharacterModal = value; }
private bool ShowEditCharacterModal { get => State.ShowEditCharacterModal; set => State.ShowEditCharacterModal = value; }
private bool CanEditCharacterOwner { get => State.CanEditCharacterOwner; set => State.CanEditCharacterOwner = value; }
private Guid? EditingCharacterId { get => State.EditingCharacterId; set => State.EditingCharacterId = value; }
private CharacterFormModel CreateCharacterInitialModel { get => State.CreateCharacterInitialModel; set => State.CreateCharacterInitialModel = value; }
private CharacterFormModel EditCharacterInitialModel { get => State.EditCharacterInitialModel; set => State.EditCharacterInitialModel = value; }
private int CreateCharacterFormVersion { get => State.CreateCharacterFormVersion; set => State.CreateCharacterFormVersion = value; }
private int EditCharacterFormVersion { get => State.EditCharacterFormVersion; set => State.EditCharacterFormVersion = value; }
private bool StateRefreshInProgress { get => State.StateRefreshInProgress; set => State.StateRefreshInProgress = value; }
private bool HasInteractiveRenderStarted { get => State.HasInteractiveRenderStarted; set => State.HasInteractiveRenderStarted = value; }
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
private CampaignStateSnapshot? CurrentCampaignState { get => State.CurrentCampaignState; set => State.CurrentCampaignState = value; }
private Guid? CampaignLogCursor { get => State.CampaignLogCursor; set => State.CampaignLogCursor = value; }
private Guid? ExpandedCampaignLogRollId { get => State.ExpandedCampaignLogRollId; set => State.ExpandedCampaignLogRollId = value; }
private Guid? FreshCampaignLogRollId { get => State.FreshCampaignLogRollId; set => State.FreshCampaignLogRollId = value; }
private Dictionary<Guid, CampaignRollDetail> CampaignLogDetails => State.CampaignLogDetails;
private HashSet<Guid> CampaignLogDetailsLoading => State.CampaignLogDetailsLoading;
private Dictionary<Guid, string> CampaignLogDetailErrors => State.CampaignLogDetailErrors;
[Parameter]
public EventCallback<string?> LoggedOut { get; set; }
private string? SelectedCampaignName => State.SelectedCampaignName;
private CharacterSummary? SelectedCharacter => State.SelectedCharacter;
private CampaignRoster? PlaySelectedCampaign => State.PlaySelectedCampaign;
private CharacterSummary? PlaySelectedCharacter => State.PlaySelectedCharacter;
private Guid? PlaySelectedCharacterId => State.PlaySelectedCharacterId;
private List<CharacterSheetSkill> PlaySelectedCharacterSkills => State.PlaySelectedCharacterSkills;
private List<CharacterSheetSkillGroup> PlaySelectedCharacterSkillGroups => State.PlaySelectedCharacterSkillGroups;
private List<CampaignLogListEntry> PlayVisibleCampaignLog => State.PlayVisibleCampaignLog;
private bool IsCurrentUserGm => State.IsCurrentUserGm;
private bool IsCurrentUserAdmin => State.IsCurrentUserAdmin;
private bool CanDeleteSelectedCampaign => State.CanDeleteSelectedCampaign;
private bool IsSelectedCampaignD6 => State.IsSelectedCampaignD6;
private WorkspaceState State { get; } = new();
private bool IsPlayScreen => State.IsPlayScreen;
private bool IsManagementScreen => State.IsManagementScreen;
private bool IsAdminScreen => State.IsAdminScreen;
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(
State,
Feedback,
JS,
WorkspaceQuery,
EnsureSelectedCharacterActiveAsync,
RefreshSelectedCharacterSheetAsync,
RefreshCampaignLogAsync,
ResetCampaignLogDetailState,
ResetCampaignStateTracking,
Play.EnsureSelectedCharacterActiveAsync,
Play.RefreshSelectedCharacterSheetAsync,
Play.RefreshCampaignLogAsync,
Play.ResetCampaignLogDetailState,
Play.ResetCampaignStateTracking,
ClearAuthenticatedState,
StopStateEventsAsync,
message => LoggedOut.InvokeAsync(message));
private WorkspaceLiveStateController Live => m_Live ??= new(
State,
Feedback,
StartStateEventsCoreAsync,
StopStateEventsCoreAsync,
RefreshCampaignRosterAsync,
RefreshSelectedCharacterSheetAsync,
RefreshCampaignLogAsync,
Scope.RefreshCampaignRosterAsync,
Play.RefreshSelectedCharacterSheetAsync,
Play.RefreshCampaignLogAsync,
() => InvokeAsync(StateHasChanged));
private WorkspacePlayCoordinator Play => m_Play ??= new(
State,
Feedback,
@@ -342,16 +117,18 @@ public partial class Workspace : IAsyncDisposable
WorkspaceQuery,
CanEditCharacter,
() => InvokeAsync(StateHasChanged));
private WorkspaceCampaignCoordinator CampaignsFlow => m_CampaignsFlow ??= new(
private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new(
State,
Feedback,
JS,
ApiClient,
LoadKnownUsernamesAsync,
ReloadCampaignsAsync,
ReloadCharacterCampaignOptionsAsync,
RefreshCampaignScopeAsync,
SyncStateEventsAsync);
Session.LoadKnownUsernamesAsync,
Scope.ReloadCampaignsAsync,
Scope.ReloadCharacterCampaignOptionsAsync,
Scope.RefreshCampaignScopeAsync,
Live.SyncStateEventsAsync);
private WorkspaceAdminCoordinator Admin => m_Admin ??= new(
State,
Feedback,
@@ -361,50 +138,51 @@ public partial class Workspace : IAsyncDisposable
ClearAuthenticatedState,
StopStateEventsAsync,
message => LoggedOut.InvokeAsync(message));
private WorkspaceFeedbackService Feedback => m_Feedback ??= new(State, () => InvokeAsync(StateHasChanged));
private WorkspaceSessionCoordinator Session => m_Session ??= new(
State,
Feedback,
JS,
ApiClient,
WorkspaceQuery,
ReloadCampaignsAsync,
ReloadCharacterCampaignOptionsAsync,
RefreshCampaignScopeAsync,
SyncStateEventsAsync,
StopStateEventsAsync,
Scope.ReloadCampaignsAsync,
Scope.ReloadCharacterCampaignOptionsAsync,
Scope.RefreshCampaignScopeAsync,
Live.SyncStateEventsAsync,
Live.StopStateEventsAsync,
EnsureAdminUsersLoadedAsync,
ResetCampaignLogDetailState,
Play.ResetCampaignLogDetailState,
() => InvokeAsync(StateHasChanged),
message => LoggedOut.InvokeAsync(message));
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems
{
get
{
var items = new List<AppHeaderMenuItem>
{
new() { Label = "Play", IsActive = IsPlayScreen, OnSelected = SwitchToPlayAsync },
new() { Label = "Campaign Management", IsActive = IsManagementScreen, OnSelected = SwitchToManagementAsync }
new() { Label = "Play", IsActive = State.IsPlayScreen, OnSelected = () => Session.SwitchScreenAsync("play") },
new() { Label = "Campaign Management", IsActive = State.IsManagementScreen, OnSelected = () => Session.SwitchScreenAsync("management") }
};
if (IsCurrentUserAdmin)
items.Add(new AppHeaderMenuItem { Label = "Admin", IsActive = IsAdminScreen, OnSelected = SwitchToAdminAsync });
if (State.IsCurrentUserAdmin)
items.Add(new AppHeaderMenuItem { Label = "Admin", IsActive = State.IsAdminScreen, OnSelected = () => Session.SwitchScreenAsync(ScreenAdmin) });
return items;
}
}
private string ConnectionStateLabel => State.ConnectionStateLabel;
private string ConnectionStateCssClass => State.ConnectionStateCssClass;
private string AppCssClass => State.AppCssClass;
private string AdminDatabaseDownloadUrl => Navigation.ToAbsoluteUri("api/admin/database").ToString();
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
private const string ScreenAdmin = "admin";
private WorkspaceCampaignScopeCoordinator? m_Scope;
private WorkspaceLiveStateController? m_Live;
private WorkspacePlayCoordinator? m_Play;
private WorkspaceCampaignCoordinator? m_CampaignsFlow;
private WorkspaceCampaignCoordinator? m_Campaigns;
private WorkspaceAdminCoordinator? m_Admin;
private WorkspaceFeedbackService? m_Feedback;
private WorkspaceSessionCoordinator? m_Session;

View File

@@ -1,5 +1,6 @@
using RpgRoller.Contracts;
using RpgRoller.Domain;
using RpgRoller.Components.Pages.HomeControls;
namespace RpgRoller.Components.Pages;
@@ -147,4 +148,37 @@ public sealed class WorkspaceState
};
public string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
public string OwnerLabel(Guid ownerUserId)
{
if (User is not null && ownerUserId == User.Id)
return "You";
if (SelectedCampaign is null)
return "Unknown owner";
if (ownerUserId == SelectedCampaign.Gm.Id)
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
var ownerDisplayName = SelectedCampaign.Characters
.Where(character => character.OwnerUserId == ownerUserId)
.Select(character => character.OwnerDisplayName)
.FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
}
public string SkillDefinitionLabel(CharacterSheetSkill skill)
{
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange);
return skill.DiceRollDefinition;
}
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}";
}
}

View File

@@ -19,8 +19,8 @@ The user-visible proof is intentionally boring: after starting the app, logging
- [x] (2026-04-04 23:03Z) Completed backend shared-helper consolidation. `GameStateStore` now owns campaign-state version mutations, `GameAuthorization`, `GameContextResolver`, and `GameDtoMapper` now own the shared helper seams, and the domain services delegate to them instead of keeping private copies.
- [x] (2026-04-04 23:20Z) Completed backend roll decomposition. Dice execution now lives in `RollEngine`, `StandardRollEngine`, `D6RollEngine`, and `RolemasterRollEngine`, while `RollBreakdownFormatter` and `CampaignLogSummaryBuilder` own the extracted formatting and compact-log helpers.
- [x] (2026-04-04 23:03Z) Finished thinning `RpgRoller/Services/GameService.cs` for startup and campaign-state bootstrap. The constructor now loads persistence and rebuilds campaign-state versions through `GameStateStore` without keeping private helper methods.
- [ ] Finish thinning `RpgRoller/Components/Pages/Workspace.razor.cs`. Remaining work: remove the large mirror of `WorkspaceState` properties and the excess pass-through wrappers so the file acts as a composition root plus lifecycle and JS-invokable bridge.
- [ ] Update `README.md` and this ExecPlan after the remaining code changes land so the documentation reflects the final, not intermediate, structure. Completed in this iteration: backend helper descriptions and current remaining scope.
- [x] (2026-04-04 23:17Z) Finished thinning `RpgRoller/Components/Pages/Workspace.razor.cs`. The mirror block is gone, the Razor file binds through `State` and coordinator surfaces directly, and `WorkspaceState` now owns the pure owner-label and skill-label projections.
- [x] (2026-04-04 23:17Z) Updated `README.md` and this ExecPlan so the documentation reflects the completed backend and frontend decomposition structure.
## Surprises & Discoveries
@@ -36,6 +36,9 @@ The user-visible proof is intentionally boring: after starting the app, logging
- Observation: the roll split preserved behavior cleanly because the extracted helper boundaries were already pure and ruleset-scoped.
Evidence: after moving dice execution into ruleset engines and moving summary text into `CampaignLogSummaryBuilder`, the existing D6, Rolemaster, log paging, detail, custom-roll, and Playwright smoke tests passed without contract changes.
- Observation: the frontend cleanup was safer once the Razor file bound straight to `State` because the mirror aliases were only indirection, not logic.
Evidence: removing the alias block from `Workspace.razor.cs` did not require coordinator behavior changes; the existing smoke tests still covered play, custom rolls, and Rolemaster UI flows successfully.
- Observation: the frontend refactor introduced one extra collaborator that was not named in the original blueprint, and that collaborator is worth keeping.
Evidence: `RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs` now owns selected-campaign reload, selected-character synchronization, log reset, and unauthorized-session handling. Those behaviors are cohesive and should not be pushed back into `Workspace.razor.cs`.
@@ -67,6 +70,10 @@ The user-visible proof is intentionally boring: after starting the app, logging
Rationale: the new engines depend only on `IDiceRoller`, so local construction keeps the facade wiring small while still carving the algorithmic work out of the service orchestration path.
Date/Author: 2026-04-04 / Codex
- Decision: Keep a few tiny wrapper methods in `Workspace.razor.cs` only where they break coordinator-construction cycles or support component-local lifecycle behavior.
Rationale: direct binding through `State`, `Session`, `Campaigns`, `Play`, `Admin`, `Scope`, and `Live` removed the noisy aliases, but a minimal set of wrapper methods still keeps lazy coordinator construction acyclic and the composition root readable.
Date/Author: 2026-04-04 / Codex
- Decision: Keep validation instructions in this ExecPlan even though this revision is documentation-only.
Rationale: `PLANS.md` requires executable validation guidance, but the user explicitly requested no CI or test work for this pass. The commands remain here for the implementation pass that follows later.
Date/Author: 2026-04-04 / Codex
@@ -75,7 +82,7 @@ The user-visible proof is intentionally boring: after starting the app, logging
The repository now has the shared backend seams that the earlier rewrite described as missing. `GameStateStore` owns campaign-state version mutation, `GameAuthorization` owns shared access checks, `GameContextResolver` owns session and campaign resolution, and `GameDtoMapper` owns the backend read-model construction that had been repeated across services.
The remaining work is narrower than before. The repository now needs the final `Workspace` binding cleanup. `GameService` is already at the intended facade shape, and `GameRollService` is now primarily orchestration plus persistence-facing log record handling.
The planned decomposition work is now complete. `GameService` is at the intended facade shape, `GameRollService` is primarily orchestration plus persistence-facing log record handling, and `Workspace.razor.cs` now reads as a composition root instead of a duplicated state bag.
## Context and Orientation
@@ -87,9 +94,7 @@ The current backend state is better than the old monolith. `RpgRoller/Services/G
The backend shared-helper duplication is now resolved. `RpgRoller/Services/GameStateStore.cs` owns campaign-state version mutations. `RpgRoller/Services/GameAuthorization.cs` owns shared access checks. `RpgRoller/Services/GameContextResolver.cs` owns session-token and campaign resolution. `RpgRoller/Services/GameDtoMapper.cs` owns the backend read models returned by the services. Roll execution is now split across `RpgRoller/Services/RollEngine.cs`, `StandardRollEngine.cs`, `D6RollEngine.cs`, `RolemasterRollEngine.cs`, `RollBreakdownFormatter.cs`, and `CampaignLogSummaryBuilder.cs`, leaving `RpgRoller/Services/GameRollService.cs` as a smaller workflow coordinator.
The current frontend state is also better than the old monolith. `RpgRoller/Components/Pages/WorkspaceState.cs` holds most UI state and many computed projections. Session/bootstrap behavior lives in `WorkspaceSessionCoordinator.cs`. Campaign management and modal flows live in `WorkspaceCampaignCoordinator.cs`. Selected campaign scope refresh lives in `WorkspaceCampaignScopeCoordinator.cs`. Play/log behavior lives in `WorkspacePlayCoordinator.cs`. Admin behavior lives in `WorkspaceAdminCoordinator.cs`. Live event reconciliation lives in `WorkspaceLiveStateController.cs`. Toast and announcement behavior lives in `WorkspaceFeedbackService.cs`.
The remaining frontend problem is that `RpgRoller/Components/Pages/Workspace.razor.cs` still mirrors a large amount of `WorkspaceState` into local alias properties and exposes many single-line wrapper methods only because the Razor file has not been fully retargeted to the composed surface. The next pass should delete those mirrors rather than add more wrappers.
The frontend state is now at the intended shape. `RpgRoller/Components/Pages/WorkspaceState.cs` holds plain UI state plus pure computed and formatting projections. Session/bootstrap behavior lives in `WorkspaceSessionCoordinator.cs`. Campaign management and modal flows live in `WorkspaceCampaignCoordinator.cs`. Selected campaign scope refresh lives in `WorkspaceCampaignScopeCoordinator.cs`. Play/log behavior lives in `WorkspacePlayCoordinator.cs`. Admin behavior lives in `WorkspaceAdminCoordinator.cs`. Live event reconciliation lives in `WorkspaceLiveStateController.cs`. Toast and announcement behavior lives in `WorkspaceFeedbackService.cs`. `RpgRoller/Components/Pages/Workspace.razor.cs` is now mainly the composition root that wires those collaborators together.
## Plan of Work
@@ -134,7 +139,7 @@ Start every future implementation pass by re-reading the plan and checking the c
git status --short
rg --files RpgRoller/Services RpgRoller/Components/Pages
The remaining implementation pass is frontend-focused. Begin by inspecting the `Workspace` composition surface before editing:
The implementation work is complete. If a future contributor needs to re-check the final frontend composition surface, start here:
Get-Content RpgRoller\Components\Pages\Workspace.razor.cs
Get-Content RpgRoller\Components\Pages\Workspace.razor
@@ -142,17 +147,7 @@ The remaining implementation pass is frontend-focused. Begin by inspecting the `
Get-Content RpgRoller\Components\Pages\WorkspaceCampaignScopeCoordinator.cs
Get-Content RpgRoller\Components\Pages\WorkspacePlayCoordinator.cs
Keep the next extraction small. Remove one block of mirrored state or pass-through wrappers at a time so component behavior can stay stable and the Playwright smoke flow can keep proving the result.
When beginning frontend cleanup, inspect the current composition surface before editing:
Get-Content RpgRoller\Components\Pages\Workspace.razor.cs
Get-Content RpgRoller\Components\Pages\Workspace.razor
Get-Content RpgRoller\Components\Pages\WorkspaceState.cs
Get-Content RpgRoller\Components\Pages\WorkspaceCampaignScopeCoordinator.cs
Get-Content RpgRoller\Components\Pages\WorkspacePlayCoordinator.cs
Move pure projections into `WorkspaceState`, simplify the composition root, and then retarget the Razor file to the composed surface. Keep the child components in `RpgRoller/Components/Pages/HomeControls/` stable unless a binding signature must change to support the cleanup.
The final frontend layout keeps child-component contracts stable while binding through `State` and the coordinator surfaces directly.
When code work resumes later, validate after each meaningful iteration with the repo-standard commands:
@@ -163,7 +158,7 @@ The expected result is simple: no failing tests, no coverage regression, and the
## Validation and Acceptance
This implementation revision ran targeted helper tests during extraction and later full repo validation through `pwsh ./scripts/ci-local.ps1`. The same full validation remains mandatory after the frontend pass.
This implementation revision ran targeted helper tests during extraction, added `WorkspaceState` tests for the new pure projections, and then ran full repo validation through `pwsh ./scripts/ci-local.ps1`.
The backend is accepted when `RpgRoller/Services/GameService.cs` contains only collaborator wiring, ruleset enumeration, and public delegation; when shared authorization, context, mapping, and campaign-state helper logic each live in one place; and when `RpgRoller/Services/GameRollService.cs` no longer embeds the dice engines or compact log summary builders.
@@ -275,3 +270,5 @@ Revision note (2026-04-04): Replaced the old blueprint with an ExecPlan, reconci
Revision note (2026-04-04 23:03Z): Marked backend shared-helper consolidation and `GameService` facade thinning as complete after implementing `GameAuthorization`, `GameContextResolver`, `GameDtoMapper`, and `GameStateStore` tracker methods. Updated the remaining scope so the next pass starts with `GameRollService` decomposition and later `Workspace` cleanup.
Revision note (2026-04-04 23:20Z): Marked backend roll decomposition as complete after extracting `RollEngine`, the ruleset-specific engines, `RollBreakdownFormatter`, and `CampaignLogSummaryBuilder`. Updated the remaining scope so the next pass can focus entirely on `Workspace` cleanup.
Revision note (2026-04-04 23:17Z): Marked `Workspace` cleanup as complete after deleting the state-mirroring alias block, moving pure display helpers into `WorkspaceState`, rebinding the Razor file through `State` and coordinator surfaces, and adding `WorkspaceState` coverage tests.