Extract workspace live state controller
This commit is contained in:
@@ -35,7 +35,7 @@ Frontend:
|
|||||||
- `RpgRoller/Components/Pages/Home.razor`: minimal gateway page (loading/auth/workspace switch)
|
- `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/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/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/**/*.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/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`
|
- `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor`
|
||||||
|
|||||||
@@ -213,90 +213,29 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
private bool CanEditSkill(CharacterSheetSkill skill) => Play.CanEditSkill(skill);
|
private bool CanEditSkill(CharacterSheetSkill skill) => Play.CanEditSkill(skill);
|
||||||
|
|
||||||
[JSInvokable]
|
[JSInvokable]
|
||||||
public async Task OnStateEventReceived(CampaignStateSnapshot state)
|
public Task OnStateEventReceived(CampaignStateSnapshot state) => Live.OnStateEventReceivedAsync(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[JSInvokable]
|
[JSInvokable]
|
||||||
public Task OnConnectionStateChanged(string state)
|
public Task OnConnectionStateChanged(string state) => Live.OnConnectionStateChangedAsync(state);
|
||||||
|
|
||||||
|
private Task SyncStateEventsAsync() => Live.SyncStateEventsAsync();
|
||||||
|
|
||||||
|
private Task StopStateEventsAsync() => Live.StopStateEventsAsync();
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
ConnectionState = state switch
|
await StopStateEventsAsync();
|
||||||
{
|
DotNetRef?.Dispose();
|
||||||
"connected" => "connected",
|
|
||||||
"reconnecting" => "reconnecting",
|
|
||||||
_ => "offline"
|
|
||||||
};
|
|
||||||
|
|
||||||
if (ConnectionState == "reconnecting")
|
|
||||||
Announce("Reconnecting to live updates.");
|
|
||||||
|
|
||||||
if (ConnectionState == "offline")
|
|
||||||
Announce("Live updates offline. Use manual refresh.");
|
|
||||||
|
|
||||||
return InvokeAsync(StateHasChanged);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SyncStateEventsAsync()
|
private async Task StartStateEventsCoreAsync(Guid campaignId)
|
||||||
{
|
{
|
||||||
if (User is null || !SelectedCampaignId.HasValue || IsAdminScreen)
|
|
||||||
{
|
|
||||||
await StopStateEventsAsync();
|
|
||||||
ConnectionState = "offline";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
DotNetRef ??= DotNetObjectReference.Create(this);
|
DotNetRef ??= DotNetObjectReference.Create(this);
|
||||||
await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", SelectedCampaignId.Value.ToString(), DotNetRef);
|
await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", campaignId.ToString(), DotNetRef);
|
||||||
ConnectionState = "reconnecting";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task StopStateEventsAsync()
|
private async Task StopStateEventsCoreAsync()
|
||||||
{
|
{
|
||||||
if (!HasInteractiveRenderStarted)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents");
|
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)
|
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
|
||||||
{
|
{
|
||||||
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
|
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -401,16 +334,6 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
|
|
||||||
private void ResetCampaignStateTracking() => Play.ResetCampaignStateTracking();
|
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]
|
[Inject]
|
||||||
private IJSRuntime JS { get; set; } = null!;
|
private IJSRuntime JS { get; set; } = null!;
|
||||||
|
|
||||||
@@ -492,6 +415,15 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
private bool IsPlayScreen => State.IsPlayScreen;
|
private bool IsPlayScreen => State.IsPlayScreen;
|
||||||
private bool IsManagementScreen => State.IsManagementScreen;
|
private bool IsManagementScreen => State.IsManagementScreen;
|
||||||
private bool IsAdminScreen => State.IsAdminScreen;
|
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(
|
private WorkspacePlayCoordinator Play => m_Play ??= new(
|
||||||
State,
|
State,
|
||||||
Feedback,
|
Feedback,
|
||||||
@@ -561,6 +493,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
private const string MobilePanelSessionKey = "play-panel";
|
private const string MobilePanelSessionKey = "play-panel";
|
||||||
private const int CampaignLogWindowSize = 25;
|
private const int CampaignLogWindowSize = 25;
|
||||||
|
|
||||||
|
private WorkspaceLiveStateController? m_Live;
|
||||||
private WorkspacePlayCoordinator? m_Play;
|
private WorkspacePlayCoordinator? m_Play;
|
||||||
private WorkspaceCampaignCoordinator? m_CampaignsFlow;
|
private WorkspaceCampaignCoordinator? m_CampaignsFlow;
|
||||||
private WorkspaceAdminCoordinator? m_Admin;
|
private WorkspaceAdminCoordinator? m_Admin;
|
||||||
|
|||||||
132
RpgRoller/Components/Pages/WorkspaceLiveStateController.cs
Normal file
132
RpgRoller/Components/Pages/WorkspaceLiveStateController.cs
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user