diff --git a/README.md b/README.md index 0a36f51..51887b7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/RpgRoller.Tests/Services/WorkspaceStateTests.cs b/RpgRoller.Tests/Services/WorkspaceStateTests.cs new file mode 100644 index 0000000..0dfb904 --- /dev/null +++ b/RpgRoller.Tests/Services/WorkspaceStateTests.cs @@ -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); + } +} diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index 05a461b..9f6eaba 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -1,111 +1,111 @@ @using RpgRoller.Components.Pages.HomeControls -
-

@LiveAnnouncement

+
+

@State.LiveAnnouncement

- @if (HasHealthIssue) + @if (State.HasHealthIssue) { }
+ LogoutRequested="Session.LogoutAsync"/> - @if (IsPlayScreen) + @if (State.IsPlayScreen) { -
+
+ 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"/> + 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"/>
} - else if (IsManagementScreen) + else if (State.IsManagementScreen) { + 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) {
- @if (IsCurrentUserAdmin) + @if (State.IsCurrentUserAdmin) {
@@ -121,22 +121,22 @@

User Management

- @if (IsAdminDataLoading) + @if (State.IsAdminDataLoading) {

Loading users...

} - else if (!IsCurrentUserAdmin) + else if (!State.IsCurrentUserAdmin) {

Admin role is required to manage users.

} - else if (AdminUsers.Count == 0) + else if (State.AdminUsers.Count == 0) {

No users found.

} else {
    - @foreach (var user in AdminUsers) + @foreach (var user in State.AdminUsers) {
  • @@ -147,15 +147,15 @@
    @@ -169,10 +169,10 @@ }
    - @if (Toasts.Count > 0) + @if (State.Toasts.Count > 0) {
    - @foreach (var toast in Toasts) + @foreach (var toast in State.Toasts) {

    @toast.Message

    @@ -183,35 +183,35 @@
    + AvailableUsernames="State.KnownUsernames" + CharacterSaved="Campaigns.OnCharacterCreatedAsync" + CancelRequested="Campaigns.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"/> diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index 4525577..786cf05 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -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 Campaigns { get => State.Campaigns; set => State.Campaigns = value; } - private List CharacterCampaignOptions { get => State.CharacterCampaignOptions; set => State.CharacterCampaignOptions = value; } - private List SelectedCharacterSkills { get => State.SelectedCharacterSkills; set => State.SelectedCharacterSkills = value; } - private List SelectedCharacterSkillGroups { get => State.SelectedCharacterSkillGroups; set => State.SelectedCharacterSkillGroups = value; } - private List CampaignLog { get => State.CampaignLog; set => State.CampaignLog = value; } - private List Rulesets { get => State.Rulesets; set => State.Rulesets = value; } - private List 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 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 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? 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 CampaignLogDetails => State.CampaignLogDetails; - private HashSet CampaignLogDetailsLoading => State.CampaignLogDetailsLoading; - private Dictionary CampaignLogDetailErrors => State.CampaignLogDetailErrors; - [Parameter] public EventCallback 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 PlaySelectedCharacterSkills => State.PlaySelectedCharacterSkills; - private List PlaySelectedCharacterSkillGroups => State.PlaySelectedCharacterSkillGroups; - private List 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 HeaderMenuItems { get { var items = new List { - 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? 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; diff --git a/RpgRoller/Components/Pages/WorkspaceState.cs b/RpgRoller/Components/Pages/WorkspaceState.cs index fca7a3f..abe75ea 100644 --- a/RpgRoller/Components/Pages/WorkspaceState.cs +++ b/RpgRoller/Components/Pages/WorkspaceState.cs @@ -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}"; + } } diff --git a/TASKS.md b/TASKS.md index e4f20b0..1a46122 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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.