Extract workspace live state controller

This commit is contained in:
2026-04-05 00:18:29 +02:00
parent 4d5d112168
commit 93c19f0705
3 changed files with 156 additions and 91 deletions

View File

@@ -35,7 +35,7 @@ Frontend:
- `RpgRoller/Components/Pages/Home.razor`: minimal gateway page (loading/auth/workspace switch)
- `RpgRoller/Components/Pages/Home.razor.cs`: single `Home` code-behind with only gateway/session-view orchestration
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI and workspace-specific state/logic
- `RpgRoller/Components/Pages/WorkspaceState.cs`, `WorkspaceToast.cs`, `WorkspaceFeedbackService.cs`, `WorkspaceSessionCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, and `WorkspacePlayCoordinator.cs`: extracted workspace UI state, toast records, feedback/status handling, session/bootstrap orchestration, admin user actions, campaign-management/modal flows, and play/log coordination while `Workspace` remains the behavior owner
- `RpgRoller/Components/Pages/WorkspaceState.cs`, `WorkspaceToast.cs`, `WorkspaceFeedbackService.cs`, `WorkspaceSessionCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, `WorkspacePlayCoordinator.cs`, and `WorkspaceLiveStateController.cs`: extracted workspace UI state, toast records, feedback/status handling, session/bootstrap orchestration, admin user actions, campaign-management/modal flows, play/log coordination, and live-state reconciliation while `Workspace` remains the behavior owner
- `RpgRoller/Components/**/*.razor.cs`: component code-behind classes (state, handlers, parameters, injected dependencies); `.razor` files remain markup-focused
- `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState<TModel>` + page form models
- `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor`

View File

@@ -213,90 +213,29 @@ public partial class Workspace : IAsyncDisposable
private bool CanEditSkill(CharacterSheetSkill skill) => Play.CanEditSkill(skill);
[JSInvokable]
public async Task OnStateEventReceived(CampaignStateSnapshot state)
{
if (StateRefreshInProgress)
return;
if (!SelectedCampaignId.HasValue || state.CampaignId != SelectedCampaignId.Value)
return;
StateRefreshInProgress = true;
try
{
if (CurrentCampaignState is null)
{
CurrentCampaignState = state;
return;
}
var previousState = CurrentCampaignState;
var previousSelectedCharacterId = SelectedCharacterId;
var previousSelectedCharacterVersion = GetCharacterVersion(previousState, previousSelectedCharacterId);
var rosterChanged = state.RosterVersion != previousState.RosterVersion;
var logChanged = IsPlayScreen && state.LogVersion != previousState.LogVersion;
if (rosterChanged)
await RefreshCampaignRosterAsync();
var selectedCharacterChanged = previousSelectedCharacterId != SelectedCharacterId;
var selectedCharacterVersionChanged = IsPlayScreen &&
!selectedCharacterChanged &&
GetCharacterVersion(state, SelectedCharacterId) != previousSelectedCharacterVersion;
if (IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged))
await RefreshSelectedCharacterSheetAsync();
if (logChanged)
await RefreshCampaignLogAsync(CampaignLogCursor);
CurrentCampaignState = state;
}
finally
{
StateRefreshInProgress = false;
await InvokeAsync(StateHasChanged);
}
}
public Task OnStateEventReceived(CampaignStateSnapshot state) => Live.OnStateEventReceivedAsync(state);
[JSInvokable]
public Task OnConnectionStateChanged(string state)
{
ConnectionState = state switch
{
"connected" => "connected",
"reconnecting" => "reconnecting",
_ => "offline"
};
public Task OnConnectionStateChanged(string state) => Live.OnConnectionStateChangedAsync(state);
if (ConnectionState == "reconnecting")
Announce("Reconnecting to live updates.");
private Task SyncStateEventsAsync() => Live.SyncStateEventsAsync();
if (ConnectionState == "offline")
Announce("Live updates offline. Use manual refresh.");
private Task StopStateEventsAsync() => Live.StopStateEventsAsync();
return InvokeAsync(StateHasChanged);
}
private async Task SyncStateEventsAsync()
{
if (User is null || !SelectedCampaignId.HasValue || IsAdminScreen)
public async ValueTask DisposeAsync()
{
await StopStateEventsAsync();
ConnectionState = "offline";
return;
DotNetRef?.Dispose();
}
DotNetRef ??= DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", SelectedCampaignId.Value.ToString(), DotNetRef);
ConnectionState = "reconnecting";
}
private async Task StopStateEventsAsync()
private async Task StartStateEventsCoreAsync(Guid campaignId)
{
if (!HasInteractiveRenderStarted)
return;
DotNetRef ??= DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", campaignId.ToString(), DotNetRef);
}
private async Task StopStateEventsCoreAsync()
{
try
{
await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents");
@@ -309,12 +248,6 @@ public partial class Workspace : IAsyncDisposable
}
}
public async ValueTask DisposeAsync()
{
await StopStateEventsAsync();
DotNetRef?.Dispose();
}
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
{
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
@@ -401,16 +334,6 @@ public partial class Workspace : IAsyncDisposable
private void ResetCampaignStateTracking() => Play.ResetCampaignStateTracking();
private static long GetCharacterVersion(CampaignStateSnapshot snapshot, Guid? characterId)
{
if (!characterId.HasValue)
return 0;
return snapshot.CharacterVersions
.FirstOrDefault(version => version.CharacterId == characterId.Value)
?.Version ?? 0;
}
[Inject]
private IJSRuntime JS { get; set; } = null!;
@@ -492,6 +415,15 @@ public partial class Workspace : IAsyncDisposable
private bool IsPlayScreen => State.IsPlayScreen;
private bool IsManagementScreen => State.IsManagementScreen;
private bool IsAdminScreen => State.IsAdminScreen;
private WorkspaceLiveStateController Live => m_Live ??= new(
State,
Feedback,
StartStateEventsCoreAsync,
StopStateEventsCoreAsync,
RefreshCampaignRosterAsync,
RefreshSelectedCharacterSheetAsync,
RefreshCampaignLogAsync,
() => InvokeAsync(StateHasChanged));
private WorkspacePlayCoordinator Play => m_Play ??= new(
State,
Feedback,
@@ -561,6 +493,7 @@ public partial class Workspace : IAsyncDisposable
private const string MobilePanelSessionKey = "play-panel";
private const int CampaignLogWindowSize = 25;
private WorkspaceLiveStateController? m_Live;
private WorkspacePlayCoordinator? m_Play;
private WorkspaceCampaignCoordinator? m_CampaignsFlow;
private WorkspaceAdminCoordinator? m_Admin;

View File

@@ -0,0 +1,132 @@
using System.Diagnostics.CodeAnalysis;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspaceLiveStateController
{
public WorkspaceLiveStateController(
WorkspaceState state,
WorkspaceFeedbackService feedback,
Func<Guid, Task> startStateEventsAsync,
Func<Task> stopStateEventsCoreAsync,
Func<Task> refreshCampaignRosterAsync,
Func<Task> refreshSelectedCharacterSheetAsync,
Func<Guid?, Task> refreshCampaignLogAsync,
Func<Task> requestRefreshAsync)
{
m_State = state;
m_Feedback = feedback;
m_StartStateEventsAsync = startStateEventsAsync;
m_StopStateEventsCoreAsync = stopStateEventsCoreAsync;
m_RefreshCampaignRosterAsync = refreshCampaignRosterAsync;
m_RefreshSelectedCharacterSheetAsync = refreshSelectedCharacterSheetAsync;
m_RefreshCampaignLogAsync = refreshCampaignLogAsync;
m_RequestRefreshAsync = requestRefreshAsync;
}
public async Task OnStateEventReceivedAsync(CampaignStateSnapshot state)
{
if (m_State.StateRefreshInProgress)
return;
if (!m_State.SelectedCampaignId.HasValue || state.CampaignId != m_State.SelectedCampaignId.Value)
return;
m_State.StateRefreshInProgress = true;
try
{
if (m_State.CurrentCampaignState is null)
{
m_State.CurrentCampaignState = state;
return;
}
var previousState = m_State.CurrentCampaignState;
var previousSelectedCharacterId = m_State.SelectedCharacterId;
var previousSelectedCharacterVersion = GetCharacterVersion(previousState, previousSelectedCharacterId);
var rosterChanged = state.RosterVersion != previousState.RosterVersion;
var logChanged = m_State.IsPlayScreen && state.LogVersion != previousState.LogVersion;
if (rosterChanged)
await m_RefreshCampaignRosterAsync();
var selectedCharacterChanged = previousSelectedCharacterId != m_State.SelectedCharacterId;
var selectedCharacterVersionChanged = m_State.IsPlayScreen &&
!selectedCharacterChanged &&
GetCharacterVersion(state, m_State.SelectedCharacterId) != previousSelectedCharacterVersion;
if (m_State.IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged))
await m_RefreshSelectedCharacterSheetAsync();
if (logChanged)
await m_RefreshCampaignLogAsync(m_State.CampaignLogCursor);
m_State.CurrentCampaignState = state;
}
finally
{
m_State.StateRefreshInProgress = false;
await m_RequestRefreshAsync();
}
}
public async Task OnConnectionStateChangedAsync(string state)
{
m_State.ConnectionState = state switch
{
"connected" => "connected",
"reconnecting" => "reconnecting",
_ => "offline"
};
if (m_State.ConnectionState == "reconnecting")
m_Feedback.Announce("Reconnecting to live updates.");
if (m_State.ConnectionState == "offline")
m_Feedback.Announce("Live updates offline. Use manual refresh.");
await m_RequestRefreshAsync();
}
public async Task SyncStateEventsAsync()
{
if (m_State.User is null || !m_State.SelectedCampaignId.HasValue || m_State.IsAdminScreen)
{
await StopStateEventsAsync();
m_State.ConnectionState = "offline";
return;
}
await m_StartStateEventsAsync(m_State.SelectedCampaignId.Value);
m_State.ConnectionState = "reconnecting";
}
public async Task StopStateEventsAsync()
{
if (!m_State.HasInteractiveRenderStarted)
return;
await m_StopStateEventsCoreAsync();
}
private static long GetCharacterVersion(CampaignStateSnapshot snapshot, Guid? characterId)
{
if (!characterId.HasValue)
return 0;
return snapshot.CharacterVersions
.FirstOrDefault(version => version.CharacterId == characterId.Value)
?.Version ?? 0;
}
private readonly WorkspaceFeedbackService m_Feedback;
private readonly Func<Guid?, Task> m_RefreshCampaignLogAsync;
private readonly Func<Task> m_RefreshCampaignRosterAsync;
private readonly Func<Task> m_RefreshSelectedCharacterSheetAsync;
private readonly Func<Task> m_RequestRefreshAsync;
private readonly Func<Guid, Task> m_StartStateEventsAsync;
private readonly WorkspaceState m_State;
private readonly Func<Task> m_StopStateEventsCoreAsync;
}