Simplify workspace composition root
This commit is contained in:
@@ -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`: gateway that switches between loading, auth, and authenticated workspace views
|
||||||
- `RpgRoller/Components/Pages/Home.razor.cs`: gateway/session orchestration for `Home`
|
- `RpgRoller/Components/Pages/Home.razor.cs`: gateway/session orchestration for `Home`
|
||||||
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI
|
- `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/Workspace.razor.cs`: workspace composition root, coordinator wiring, lifecycle, and JS-invokable entry points
|
||||||
- `RpgRoller/Components/Pages/WorkspaceState.cs`: workspace UI state and computed projections
|
- `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/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/Pages/HomeControls/`: workspace and auth child components, forms, header, panels, and modal controls
|
||||||
- `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions
|
- `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions
|
||||||
@@ -49,7 +49,7 @@ Frontend:
|
|||||||
|
|
||||||
Current repo note:
|
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.
|
- 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
|
## Runtime and Persistence
|
||||||
|
|||||||
116
RpgRoller.Tests/Services/WorkspaceStateTests.cs
Normal file
116
RpgRoller.Tests/Services/WorkspaceStateTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,111 +1,111 @@
|
|||||||
@using RpgRoller.Components.Pages.HomeControls
|
@using RpgRoller.Components.Pages.HomeControls
|
||||||
<div class="@AppCssClass">
|
<div class="@State.AppCssClass">
|
||||||
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
|
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
|
||||||
|
|
||||||
@if (HasHealthIssue)
|
@if (State.HasHealthIssue)
|
||||||
{
|
{
|
||||||
<section class="health-banner" role="alert">
|
<section class="health-banner" role="alert">
|
||||||
<div>
|
<div>
|
||||||
<strong>API currently unavailable.</strong>
|
<strong>API currently unavailable.</strong>
|
||||||
<p>@HealthIssueMessage</p>
|
<p>@State.HealthIssueMessage</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" @onclick="RetryAfterHealthIssueAsync">Retry</button>
|
<button type="button" @onclick="Session.RetryAfterHealthIssueAsync">Retry</button>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="workspace-shell">
|
<div class="workspace-shell">
|
||||||
<AppHeader
|
<AppHeader
|
||||||
User="User"
|
User="State.User"
|
||||||
ShowCampaign="true"
|
ShowCampaign="true"
|
||||||
CampaignName="@SelectedCampaignName"
|
CampaignName="@State.SelectedCampaignName"
|
||||||
ShowConnectionState="true"
|
ShowConnectionState="true"
|
||||||
ConnectionStateLabel="@ConnectionStateLabel"
|
ConnectionStateLabel="@State.ConnectionStateLabel"
|
||||||
ConnectionStateCssClass="@ConnectionStateCssClass"
|
ConnectionStateCssClass="@State.ConnectionStateCssClass"
|
||||||
IsMenuOpen="IsScreenMenuOpen"
|
IsMenuOpen="State.IsScreenMenuOpen"
|
||||||
MenuButtonId="workspace-screen-menu-button"
|
MenuButtonId="workspace-screen-menu-button"
|
||||||
MenuId="workspace-screen-menu"
|
MenuId="workspace-screen-menu"
|
||||||
MenuItems="HeaderMenuItems"
|
MenuItems="HeaderMenuItems"
|
||||||
ToggleMenuRequested="ToggleScreenMenu"
|
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
|
<CharacterPanel
|
||||||
IsCampaignDataLoading="IsCampaignDataLoading"
|
IsCampaignDataLoading="State.IsCampaignDataLoading"
|
||||||
SelectedCampaign="PlaySelectedCampaign"
|
SelectedCampaign="State.PlaySelectedCampaign"
|
||||||
SelectedCharacterId="PlaySelectedCharacterId"
|
SelectedCharacterId="State.PlaySelectedCharacterId"
|
||||||
SelectedCharacter="PlaySelectedCharacter"
|
SelectedCharacter="State.PlaySelectedCharacter"
|
||||||
IsMutating="IsMutating"
|
IsMutating="State.IsMutating"
|
||||||
SelectedCharacterSkills="PlaySelectedCharacterSkills"
|
SelectedCharacterSkills="State.PlaySelectedCharacterSkills"
|
||||||
SelectedCharacterSkillGroups="PlaySelectedCharacterSkillGroups"
|
SelectedCharacterSkillGroups="State.PlaySelectedCharacterSkillGroups"
|
||||||
SelectedCampaignRulesetId="@(PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
||||||
RollVisibility="RollVisibility"
|
RollVisibility="State.RollVisibility"
|
||||||
RollVisibilityChanged="OnRollVisibilityChanged"
|
RollVisibilityChanged="Session.OnRollVisibilityChangedAsync"
|
||||||
OwnerLabel="OwnerLabel"
|
OwnerLabel="State.OwnerLabel"
|
||||||
SkillDefinitionLabel="SkillDefinitionLabel"
|
SkillDefinitionLabel="State.SkillDefinitionLabel"
|
||||||
CanEditCharacter="CanEditCharacter"
|
CanEditCharacter="Campaigns.CanEditCharacter"
|
||||||
CanEditSkill="CanEditSkill"
|
CanEditSkill="Play.CanEditSkill"
|
||||||
CharacterSelected="SelectCharacterAsync"
|
CharacterSelected="Play.SelectCharacterAsync"
|
||||||
EditCharacterRequested="OpenEditCharacterModal"
|
EditCharacterRequested="Campaigns.OpenEditCharacterModal"
|
||||||
SkillCreated="OnSkillCreatedAsync"
|
SkillCreated="Play.OnSkillCreatedAsync"
|
||||||
SkillUpdated="OnSkillUpdatedAsync"
|
SkillUpdated="Play.OnSkillUpdatedAsync"
|
||||||
SkillGroupCreated="OnSkillGroupCreatedAsync"
|
SkillGroupCreated="Play.OnSkillGroupCreatedAsync"
|
||||||
SkillGroupUpdated="OnSkillGroupUpdatedAsync"
|
SkillGroupUpdated="Play.OnSkillGroupUpdatedAsync"
|
||||||
SkillDeleted="OnSkillDeletedAsync"
|
SkillDeleted="Play.OnSkillDeletedAsync"
|
||||||
SkillGroupDeleted="OnSkillGroupDeletedAsync"
|
SkillGroupDeleted="Play.OnSkillGroupDeletedAsync"
|
||||||
ErrorOccurred="OnCharacterPanelErrorAsync"
|
ErrorOccurred="Play.OnCharacterPanelErrorAsync"
|
||||||
RollRequested="RollSkillAsync"/>
|
RollRequested="Play.RollSkillAsync"/>
|
||||||
|
|
||||||
<CampaignLogPanel
|
<CampaignLogPanel
|
||||||
IsCampaignDataLoading="IsCampaignDataLoading"
|
IsCampaignDataLoading="State.IsCampaignDataLoading"
|
||||||
CampaignLog="PlayVisibleCampaignLog"
|
CampaignLog="State.PlayVisibleCampaignLog"
|
||||||
ExpandedRollId="ExpandedCampaignLogRollId"
|
ExpandedRollId="State.ExpandedCampaignLogRollId"
|
||||||
FreshRollId="FreshCampaignLogRollId"
|
FreshRollId="State.FreshCampaignLogRollId"
|
||||||
SelectedCharacterId="PlaySelectedCharacterId"
|
SelectedCharacterId="State.PlaySelectedCharacterId"
|
||||||
SelectedCharacterName="@(PlaySelectedCharacter?.Name)"
|
SelectedCharacterName="@(State.PlaySelectedCharacter?.Name)"
|
||||||
SelectedCampaignRulesetId="@(PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
||||||
RollVisibility="RollVisibility"
|
RollVisibility="State.RollVisibility"
|
||||||
IsMutating="IsMutating"
|
IsMutating="State.IsMutating"
|
||||||
ToggleRollDetailRequested="ToggleRollDetailAsync"
|
ToggleRollDetailRequested="Play.ToggleRollDetailAsync"
|
||||||
ResolveRollDetail="ResolveRollDetail"
|
ResolveRollDetail="Play.ResolveRollDetail"
|
||||||
IsRollDetailLoading="IsRollDetailLoading"
|
IsRollDetailLoading="Play.IsRollDetailLoading"
|
||||||
GetRollDetailError="GetRollDetailError"
|
GetRollDetailError="Play.GetRollDetailError"
|
||||||
CustomRollCreated="OnCustomRollCreatedAsync"
|
CustomRollCreated="Play.OnCustomRollCreatedAsync"
|
||||||
ErrorOccurred="OnCampaignLogPanelErrorAsync"/>
|
ErrorOccurred="Play.OnCampaignLogPanelErrorAsync"/>
|
||||||
</main>
|
</main>
|
||||||
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
||||||
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)"
|
<button type="button" class="switch @(State.MobilePanel == "character" ? "active" : string.Empty)"
|
||||||
@onclick="SetMobilePanelCharacterAsync">Character
|
@onclick='() => Scope.SetMobilePanelAsync("character")'>Character
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)"
|
<button type="button" class="switch @(State.MobilePanel == "log" ? "active" : string.Empty)"
|
||||||
@onclick="SetMobilePanelLogAsync">Log
|
@onclick='() => Scope.SetMobilePanelAsync("log")'>Log
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
}
|
}
|
||||||
else if (IsManagementScreen)
|
else if (State.IsManagementScreen)
|
||||||
{
|
{
|
||||||
<CampaignManagementPanel
|
<CampaignManagementPanel
|
||||||
Campaigns="Campaigns"
|
Campaigns="State.Campaigns"
|
||||||
SelectedCampaignId="SelectedCampaignId"
|
SelectedCampaignId="State.SelectedCampaignId"
|
||||||
SelectedCampaign="SelectedCampaign"
|
SelectedCampaign="State.SelectedCampaign"
|
||||||
Rulesets="Rulesets"
|
Rulesets="State.Rulesets"
|
||||||
IsMutating="IsMutating"
|
IsMutating="State.IsMutating"
|
||||||
OwnerLabel="OwnerLabel"
|
OwnerLabel="State.OwnerLabel"
|
||||||
CanEditCharacter="CanEditCharacter"
|
CanEditCharacter="Campaigns.CanEditCharacter"
|
||||||
CanDeleteCharacter="CanDeleteCharacter"
|
CanDeleteCharacter="Campaigns.CanDeleteCharacter"
|
||||||
CanDeleteCampaign="CanDeleteSelectedCampaign"
|
CanDeleteCampaign="State.CanDeleteSelectedCampaign"
|
||||||
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
CampaignSelectionChanged="Campaigns.OnCampaignSelectionChangedAsync"
|
||||||
CampaignCreated="OnCampaignCreatedAsync"
|
CampaignCreated="Campaigns.OnCampaignCreatedAsync"
|
||||||
DeleteCampaignRequested="DeleteSelectedCampaignAsync"
|
DeleteCampaignRequested="Campaigns.DeleteSelectedCampaignAsync"
|
||||||
CreateCharacterRequested="OpenCreateCharacterModal"
|
CreateCharacterRequested="Campaigns.OpenCreateCharacterModal"
|
||||||
EditCharacterRequested="OpenEditCharacterModal"
|
EditCharacterRequested="Campaigns.OpenEditCharacterModal"
|
||||||
DeleteCharacterRequested="DeleteCharacterAsync"/>
|
DeleteCharacterRequested="Campaigns.DeleteCharacterAsync"/>
|
||||||
}
|
}
|
||||||
else if (IsAdminScreen)
|
else if (State.IsAdminScreen)
|
||||||
{
|
{
|
||||||
<main class="management-screen">
|
<main class="management-screen">
|
||||||
@if (IsCurrentUserAdmin)
|
@if (State.IsCurrentUserAdmin)
|
||||||
{
|
{
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
@@ -121,22 +121,22 @@
|
|||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>User Management</h2>
|
<h2>User Management</h2>
|
||||||
</div>
|
</div>
|
||||||
@if (IsAdminDataLoading)
|
@if (State.IsAdminDataLoading)
|
||||||
{
|
{
|
||||||
<p class="empty">Loading users...</p>
|
<p class="empty">Loading users...</p>
|
||||||
}
|
}
|
||||||
else if (!IsCurrentUserAdmin)
|
else if (!State.IsCurrentUserAdmin)
|
||||||
{
|
{
|
||||||
<p class="empty">Admin role is required to manage users.</p>
|
<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>
|
<p class="empty">No users found.</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<ul class="management-list">
|
<ul class="management-list">
|
||||||
@foreach (var user in AdminUsers)
|
@foreach (var user in State.AdminUsers)
|
||||||
{
|
{
|
||||||
<li>
|
<li>
|
||||||
<div>
|
<div>
|
||||||
@@ -147,15 +147,15 @@
|
|||||||
<div class="skill-chip-actions">
|
<div class="skill-chip-actions">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="chip-button"
|
class="chip-button"
|
||||||
disabled="@(IsMutating || user.Id == User?.Id)"
|
disabled="@(State.IsMutating || user.Id == State.User?.Id)"
|
||||||
@onclick="() => ToggleAdminRoleAsync(user)">
|
@onclick="() => Admin.ToggleAdminRoleAsync(user)">
|
||||||
<span aria-hidden="true" class="emoji">🛡️</span>
|
<span aria-hidden="true" class="emoji">🛡️</span>
|
||||||
<span class="sr-only">Toggle admin role for @user.Username</span>
|
<span class="sr-only">Toggle admin role for @user.Username</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="chip-button"
|
class="chip-button"
|
||||||
disabled="@(IsMutating || user.Id == User?.Id)"
|
disabled="@(State.IsMutating || user.Id == State.User?.Id)"
|
||||||
@onclick="() => DeleteUserAsync(user)">
|
@onclick="() => Admin.DeleteUserAsync(user)">
|
||||||
<span aria-hidden="true" class="emoji">🗑️</span>
|
<span aria-hidden="true" class="emoji">🗑️</span>
|
||||||
<span class="sr-only">Delete user @user.Username</span>
|
<span class="sr-only">Delete user @user.Username</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -169,10 +169,10 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Toasts.Count > 0)
|
@if (State.Toasts.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="toast-stack" aria-live="polite" aria-atomic="false">
|
<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">
|
<div class="toast @(toast.IsError ? "error" : "success")" role="status">
|
||||||
<p>@toast.Message</p>
|
<p>@toast.Message</p>
|
||||||
@@ -183,35 +183,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CharacterFormModal
|
<CharacterFormModal
|
||||||
Visible="ShowCreateCharacterModal"
|
Visible="State.ShowCreateCharacterModal"
|
||||||
Title="Create Character"
|
Title="Create Character"
|
||||||
SubmitLabel="Create Character"
|
SubmitLabel="Create Character"
|
||||||
NameInputId="character-create-name"
|
NameInputId="character-create-name"
|
||||||
CampaignInputId="character-create-campaign"
|
CampaignInputId="character-create-campaign"
|
||||||
OwnerUsernameInputId="character-create-owner"
|
OwnerUsernameInputId="character-create-owner"
|
||||||
InitialModel="CreateCharacterInitialModel"
|
InitialModel="State.CreateCharacterInitialModel"
|
||||||
FormVersion="CreateCharacterFormVersion"
|
FormVersion="State.CreateCharacterFormVersion"
|
||||||
EditingCharacterId="null"
|
EditingCharacterId="null"
|
||||||
CampaignOptions="CharacterCampaignOptions"
|
CampaignOptions="State.CharacterCampaignOptions"
|
||||||
IsMutating="IsMutating"
|
IsMutating="State.IsMutating"
|
||||||
AllowOwnerEdit="false"
|
AllowOwnerEdit="false"
|
||||||
AvailableUsernames="KnownUsernames"
|
AvailableUsernames="State.KnownUsernames"
|
||||||
CharacterSaved="OnCharacterCreatedAsync"
|
CharacterSaved="Campaigns.OnCharacterCreatedAsync"
|
||||||
CancelRequested="CloseCharacterModals"/>
|
CancelRequested="Campaigns.CloseCharacterModals"/>
|
||||||
|
|
||||||
<CharacterFormModal
|
<CharacterFormModal
|
||||||
Visible="ShowEditCharacterModal"
|
Visible="State.ShowEditCharacterModal"
|
||||||
Title="Edit Character"
|
Title="Edit Character"
|
||||||
SubmitLabel="Save Character"
|
SubmitLabel="Save Character"
|
||||||
NameInputId="character-edit-name"
|
NameInputId="character-edit-name"
|
||||||
CampaignInputId="character-edit-campaign"
|
CampaignInputId="character-edit-campaign"
|
||||||
OwnerUsernameInputId="character-edit-owner"
|
OwnerUsernameInputId="character-edit-owner"
|
||||||
InitialModel="EditCharacterInitialModel"
|
InitialModel="State.EditCharacterInitialModel"
|
||||||
FormVersion="EditCharacterFormVersion"
|
FormVersion="State.EditCharacterFormVersion"
|
||||||
EditingCharacterId="EditingCharacterId"
|
EditingCharacterId="State.EditingCharacterId"
|
||||||
CampaignOptions="CharacterCampaignOptions"
|
CampaignOptions="State.CharacterCampaignOptions"
|
||||||
IsMutating="IsMutating"
|
IsMutating="State.IsMutating"
|
||||||
AllowOwnerEdit="CanEditCharacterOwner"
|
AllowOwnerEdit="State.CanEditCharacterOwner"
|
||||||
AvailableUsernames="KnownUsernames"
|
AvailableUsernames="State.KnownUsernames"
|
||||||
CharacterSaved="OnCharacterUpdatedAsync"
|
CharacterSaved="Campaigns.OnCharacterUpdatedAsync"
|
||||||
CancelRequested="CloseCharacterModals"/>
|
CancelRequested="Campaigns.CloseCharacterModals"/>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Components;
|
|||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
using RpgRoller.Components.Pages.HomeControls;
|
using RpgRoller.Components.Pages.HomeControls;
|
||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
using RpgRoller.Domain;
|
|
||||||
|
|
||||||
namespace RpgRoller.Components.Pages;
|
namespace RpgRoller.Components.Pages;
|
||||||
|
|
||||||
@@ -12,7 +11,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
HasInteractiveRenderStarted = true;
|
State.HasInteractiveRenderStarted = true;
|
||||||
if (!firstRender)
|
if (!firstRender)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -20,133 +19,26 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
await InvokeAsync(StateHasChanged);
|
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]
|
[JSInvokable]
|
||||||
public Task OnStateEventReceived(CampaignStateSnapshot state) => Live.OnStateEventReceivedAsync(state);
|
public Task OnStateEventReceived(CampaignStateSnapshot state) => Live.OnStateEventReceivedAsync(state);
|
||||||
|
|
||||||
[JSInvokable]
|
[JSInvokable]
|
||||||
public Task OnConnectionStateChanged(string state) => Live.OnConnectionStateChangedAsync(state);
|
public Task OnConnectionStateChanged(string state) => Live.OnConnectionStateChangedAsync(state);
|
||||||
|
|
||||||
private Task SyncStateEventsAsync() => Live.SyncStateEventsAsync();
|
|
||||||
|
|
||||||
private Task StopStateEventsAsync() => Live.StopStateEventsAsync();
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await StopStateEventsAsync();
|
await StopStateEventsAsync();
|
||||||
DotNetRef?.Dispose();
|
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)
|
private async Task StartStateEventsCoreAsync(Guid campaignId)
|
||||||
{
|
{
|
||||||
DotNetRef ??= DotNetObjectReference.Create(this);
|
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)
|
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
|
||||||
{
|
{
|
||||||
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
|
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]
|
[Inject]
|
||||||
private IJSRuntime JS { get; set; } = null!;
|
private IJSRuntime JS { get; set; } = null!;
|
||||||
|
|
||||||
@@ -244,97 +81,35 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
[Inject]
|
[Inject]
|
||||||
private NavigationManager Navigation { get; set; } = null!;
|
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]
|
[Parameter]
|
||||||
public EventCallback<string?> LoggedOut { get; set; }
|
public EventCallback<string?> LoggedOut { get; set; }
|
||||||
|
|
||||||
private string? SelectedCampaignName => State.SelectedCampaignName;
|
private WorkspaceState State { get; } = new();
|
||||||
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 bool IsPlayScreen => State.IsPlayScreen;
|
|
||||||
private bool IsManagementScreen => State.IsManagementScreen;
|
|
||||||
private bool IsAdminScreen => State.IsAdminScreen;
|
|
||||||
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(
|
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(
|
||||||
State,
|
State,
|
||||||
Feedback,
|
Feedback,
|
||||||
JS,
|
JS,
|
||||||
WorkspaceQuery,
|
WorkspaceQuery,
|
||||||
EnsureSelectedCharacterActiveAsync,
|
Play.EnsureSelectedCharacterActiveAsync,
|
||||||
RefreshSelectedCharacterSheetAsync,
|
Play.RefreshSelectedCharacterSheetAsync,
|
||||||
RefreshCampaignLogAsync,
|
Play.RefreshCampaignLogAsync,
|
||||||
ResetCampaignLogDetailState,
|
Play.ResetCampaignLogDetailState,
|
||||||
ResetCampaignStateTracking,
|
Play.ResetCampaignStateTracking,
|
||||||
ClearAuthenticatedState,
|
ClearAuthenticatedState,
|
||||||
StopStateEventsAsync,
|
StopStateEventsAsync,
|
||||||
message => LoggedOut.InvokeAsync(message));
|
message => LoggedOut.InvokeAsync(message));
|
||||||
|
|
||||||
private WorkspaceLiveStateController Live => m_Live ??= new(
|
private WorkspaceLiveStateController Live => m_Live ??= new(
|
||||||
State,
|
State,
|
||||||
Feedback,
|
Feedback,
|
||||||
StartStateEventsCoreAsync,
|
StartStateEventsCoreAsync,
|
||||||
StopStateEventsCoreAsync,
|
StopStateEventsCoreAsync,
|
||||||
RefreshCampaignRosterAsync,
|
Scope.RefreshCampaignRosterAsync,
|
||||||
RefreshSelectedCharacterSheetAsync,
|
Play.RefreshSelectedCharacterSheetAsync,
|
||||||
RefreshCampaignLogAsync,
|
Play.RefreshCampaignLogAsync,
|
||||||
() => InvokeAsync(StateHasChanged));
|
() => InvokeAsync(StateHasChanged));
|
||||||
|
|
||||||
private WorkspacePlayCoordinator Play => m_Play ??= new(
|
private WorkspacePlayCoordinator Play => m_Play ??= new(
|
||||||
State,
|
State,
|
||||||
Feedback,
|
Feedback,
|
||||||
@@ -342,16 +117,18 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
WorkspaceQuery,
|
WorkspaceQuery,
|
||||||
CanEditCharacter,
|
CanEditCharacter,
|
||||||
() => InvokeAsync(StateHasChanged));
|
() => InvokeAsync(StateHasChanged));
|
||||||
private WorkspaceCampaignCoordinator CampaignsFlow => m_CampaignsFlow ??= new(
|
|
||||||
|
private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new(
|
||||||
State,
|
State,
|
||||||
Feedback,
|
Feedback,
|
||||||
JS,
|
JS,
|
||||||
ApiClient,
|
ApiClient,
|
||||||
LoadKnownUsernamesAsync,
|
Session.LoadKnownUsernamesAsync,
|
||||||
ReloadCampaignsAsync,
|
Scope.ReloadCampaignsAsync,
|
||||||
ReloadCharacterCampaignOptionsAsync,
|
Scope.ReloadCharacterCampaignOptionsAsync,
|
||||||
RefreshCampaignScopeAsync,
|
Scope.RefreshCampaignScopeAsync,
|
||||||
SyncStateEventsAsync);
|
Live.SyncStateEventsAsync);
|
||||||
|
|
||||||
private WorkspaceAdminCoordinator Admin => m_Admin ??= new(
|
private WorkspaceAdminCoordinator Admin => m_Admin ??= new(
|
||||||
State,
|
State,
|
||||||
Feedback,
|
Feedback,
|
||||||
@@ -361,50 +138,51 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
ClearAuthenticatedState,
|
ClearAuthenticatedState,
|
||||||
StopStateEventsAsync,
|
StopStateEventsAsync,
|
||||||
message => LoggedOut.InvokeAsync(message));
|
message => LoggedOut.InvokeAsync(message));
|
||||||
|
|
||||||
private WorkspaceFeedbackService Feedback => m_Feedback ??= new(State, () => InvokeAsync(StateHasChanged));
|
private WorkspaceFeedbackService Feedback => m_Feedback ??= new(State, () => InvokeAsync(StateHasChanged));
|
||||||
|
|
||||||
private WorkspaceSessionCoordinator Session => m_Session ??= new(
|
private WorkspaceSessionCoordinator Session => m_Session ??= new(
|
||||||
State,
|
State,
|
||||||
Feedback,
|
Feedback,
|
||||||
JS,
|
JS,
|
||||||
ApiClient,
|
ApiClient,
|
||||||
WorkspaceQuery,
|
WorkspaceQuery,
|
||||||
ReloadCampaignsAsync,
|
Scope.ReloadCampaignsAsync,
|
||||||
ReloadCharacterCampaignOptionsAsync,
|
Scope.ReloadCharacterCampaignOptionsAsync,
|
||||||
RefreshCampaignScopeAsync,
|
Scope.RefreshCampaignScopeAsync,
|
||||||
SyncStateEventsAsync,
|
Live.SyncStateEventsAsync,
|
||||||
StopStateEventsAsync,
|
Live.StopStateEventsAsync,
|
||||||
EnsureAdminUsersLoadedAsync,
|
EnsureAdminUsersLoadedAsync,
|
||||||
ResetCampaignLogDetailState,
|
Play.ResetCampaignLogDetailState,
|
||||||
() => InvokeAsync(StateHasChanged),
|
() => InvokeAsync(StateHasChanged),
|
||||||
message => LoggedOut.InvokeAsync(message));
|
message => LoggedOut.InvokeAsync(message));
|
||||||
|
|
||||||
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems
|
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
var items = new List<AppHeaderMenuItem>
|
var items = new List<AppHeaderMenuItem>
|
||||||
{
|
{
|
||||||
new() { Label = "Play", IsActive = IsPlayScreen, OnSelected = SwitchToPlayAsync },
|
new() { Label = "Play", IsActive = State.IsPlayScreen, OnSelected = () => Session.SwitchScreenAsync("play") },
|
||||||
new() { Label = "Campaign Management", IsActive = IsManagementScreen, OnSelected = SwitchToManagementAsync }
|
new() { Label = "Campaign Management", IsActive = State.IsManagementScreen, OnSelected = () => Session.SwitchScreenAsync("management") }
|
||||||
};
|
};
|
||||||
|
|
||||||
if (IsCurrentUserAdmin)
|
if (State.IsCurrentUserAdmin)
|
||||||
items.Add(new AppHeaderMenuItem { Label = "Admin", IsActive = IsAdminScreen, OnSelected = SwitchToAdminAsync });
|
items.Add(new AppHeaderMenuItem { Label = "Admin", IsActive = State.IsAdminScreen, OnSelected = () => Session.SwitchScreenAsync(ScreenAdmin) });
|
||||||
|
|
||||||
return items;
|
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 string AdminDatabaseDownloadUrl => Navigation.ToAbsoluteUri("api/admin/database").ToString();
|
||||||
|
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
|
||||||
|
|
||||||
private const string ScreenAdmin = "admin";
|
private const string ScreenAdmin = "admin";
|
||||||
|
|
||||||
private WorkspaceCampaignScopeCoordinator? m_Scope;
|
private WorkspaceCampaignScopeCoordinator? m_Scope;
|
||||||
private WorkspaceLiveStateController? m_Live;
|
private WorkspaceLiveStateController? m_Live;
|
||||||
private WorkspacePlayCoordinator? m_Play;
|
private WorkspacePlayCoordinator? m_Play;
|
||||||
private WorkspaceCampaignCoordinator? m_CampaignsFlow;
|
private WorkspaceCampaignCoordinator? m_Campaigns;
|
||||||
private WorkspaceAdminCoordinator? m_Admin;
|
private WorkspaceAdminCoordinator? m_Admin;
|
||||||
private WorkspaceFeedbackService? m_Feedback;
|
private WorkspaceFeedbackService? m_Feedback;
|
||||||
private WorkspaceSessionCoordinator? m_Session;
|
private WorkspaceSessionCoordinator? m_Session;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
using RpgRoller.Domain;
|
using RpgRoller.Domain;
|
||||||
|
using RpgRoller.Components.Pages.HomeControls;
|
||||||
|
|
||||||
namespace RpgRoller.Components.Pages;
|
namespace RpgRoller.Components.Pages;
|
||||||
|
|
||||||
@@ -147,4 +148,37 @@ public sealed class WorkspaceState
|
|||||||
};
|
};
|
||||||
|
|
||||||
public string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
|
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}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
TASKS.md
35
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: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: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.
|
- [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.
|
- [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.
|
||||||
- [ ] 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) Updated `README.md` and this ExecPlan so the documentation reflects the completed backend and frontend decomposition structure.
|
||||||
|
|
||||||
## Surprises & Discoveries
|
## 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.
|
- 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.
|
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.
|
- 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`.
|
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.
|
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
|
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.
|
- 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.
|
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
|
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 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
|
## 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 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 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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Plan of Work
|
## Plan of Work
|
||||||
|
|
||||||
@@ -134,7 +139,7 @@ Start every future implementation pass by re-reading the plan and checking the c
|
|||||||
git status --short
|
git status --short
|
||||||
rg --files RpgRoller/Services RpgRoller/Components/Pages
|
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.cs
|
||||||
Get-Content RpgRoller\Components\Pages\Workspace.razor
|
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\WorkspaceCampaignScopeCoordinator.cs
|
||||||
Get-Content RpgRoller\Components\Pages\WorkspacePlayCoordinator.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.
|
The final frontend layout keeps child-component contracts stable while binding through `State` and the coordinator surfaces directly.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
When code work resumes later, validate after each meaningful iteration with the repo-standard commands:
|
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
|
## 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.
|
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: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: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.
|
||||||
|
|||||||
Reference in New Issue
Block a user