diff --git a/src/Koogle.Web/Components/Pages/Days/DayDetails.razor b/src/Koogle.Web/Components/Pages/Days/DayDetails.razor index afa033e..af07af7 100644 --- a/src/Koogle.Web/Components/Pages/Days/DayDetails.razor +++ b/src/Koogle.Web/Components/Pages/Days/DayDetails.razor @@ -2,7 +2,7 @@ @attribute [Authorize(Policy = "ClubViewer")] @inherits Fluxor.Blazor.Web.Components.FluxorComponent -@implements IDisposable +@implements IAsyncDisposable @using Fluxor @using Koogle.Application.DTOs @@ -12,6 +12,8 @@ @using Koogle.Web.Store.PersonState @using Koogle.Web.Store.TimerState @using Koogle.Web.Components.Game +@using Koogle.Web.Hubs +@using Koogle.Web.Services @using Microsoft.AspNetCore.Authorization @inject IState DayState @@ -22,6 +24,7 @@ @inject ISnackbar Snackbar @inject IDialogService DialogService @inject NavigationManager NavigationManager +@inject GameHubService HubService Spieltag Details @@ -875,12 +878,30 @@ else _activeTabIndex = 1; } - protected override void OnAfterRender(bool firstRender) + protected override async Task OnAfterRenderAsync(bool firstRender) { - base.OnAfterRender(firstRender); + await base.OnAfterRenderAsync(firstRender); if (firstRender) { TimerState.StateChanged += OnTimerStateChanged; + GameState.StateChanged += OnGameStateChanged; + + // Subscribe to SignalR events + HubService.OnGameStateUpdated += HandleHubGameStateUpdated; + HubService.OnThrowRecorded += HandleHubThrowRecorded; + HubService.OnGameStarted += HandleHubGameStarted; + HubService.OnGameEnded += HandleHubGameEnded; + + // Start SignalR connection and join day group + await HubService.StartAsync(); + await HubService.JoinDayAsync(DayId); + + // If there's an active game, join its group too + if (GameState.Value.ActiveGameId.HasValue) + { + await HubService.JoinGameAsync(GameState.Value.ActiveGameId.Value); + _previousGameId = GameState.Value.ActiveGameId.Value; + } } } @@ -894,8 +915,82 @@ else } } - public void Dispose() + private Guid? _previousGameId; + + private async void OnGameStateChanged(object? sender, EventArgs e) + { + var currentGameId = GameState.Value.ActiveGameId; + + // Game started - join game group + if (currentGameId.HasValue && _previousGameId != currentGameId) + { + if (_previousGameId.HasValue) + { + await HubService.LeaveGameAsync(_previousGameId.Value); + } + await HubService.JoinGameAsync(currentGameId.Value); + _previousGameId = currentGameId; + } + // Game ended - leave game group + else if (!currentGameId.HasValue && _previousGameId.HasValue) + { + await HubService.LeaveGameAsync(_previousGameId.Value); + _previousGameId = null; + } + } + + // SignalR event handlers + private void HandleHubGameStateUpdated(GameStateSerializationDto state) + { + // Dispatch action to update local state from remote + Dispatcher.Dispatch(new RemoteGameStateUpdatedAction(state)); + InvokeAsync(StateHasChanged); + } + + private void HandleHubThrowRecorded(Guid gameId, Guid playerId, int pinsKnocked) + { + // Throw was recorded by another client - refresh state + Snackbar.Add($"Wurf empfangen: {pinsKnocked} Pins", Severity.Info); + InvokeAsync(StateHasChanged); + } + + private void HandleHubGameStarted(Guid gameId, string gameTypeName) + { + // New game started - reload active game + Dispatcher.Dispatch(new LoadActiveGameAction(DayId)); + Snackbar.Add($"Neues Spiel gestartet: {gameTypeName}", Severity.Success); + InvokeAsync(StateHasChanged); + } + + private void HandleHubGameEnded(GameResultDto result) + { + // Game ended - reload completed games + Dispatcher.Dispatch(new LoadCompletedGamesAction(DayId)); + var message = result.WinnerName != null + ? $"Spiel beendet! Gewinner: {result.WinnerName}" + : "Spiel beendet!"; + Snackbar.Add(message, Severity.Success); + _activeTabIndex = 0; + InvokeAsync(StateHasChanged); + } + + public async ValueTask DisposeAsync() { TimerState.StateChanged -= OnTimerStateChanged; + GameState.StateChanged -= OnGameStateChanged; + + // Unsubscribe from SignalR events + HubService.OnGameStateUpdated -= HandleHubGameStateUpdated; + HubService.OnThrowRecorded -= HandleHubThrowRecorded; + HubService.OnGameStarted -= HandleHubGameStarted; + HubService.OnGameEnded -= HandleHubGameEnded; + + // Leave groups and stop hub connection + if (_previousGameId.HasValue) + { + await HubService.LeaveGameAsync(_previousGameId.Value); + } + await HubService.LeaveDayAsync(DayId); + await HubService.DisposeAsync(); } } diff --git a/src/Koogle.Web/Store/GameState/GameActions.cs b/src/Koogle.Web/Store/GameState/GameActions.cs index 0e000df..fb9b3c6 100644 --- a/src/Koogle.Web/Store/GameState/GameActions.cs +++ b/src/Koogle.Web/Store/GameState/GameActions.cs @@ -258,6 +258,11 @@ public record GameStateUpdatedFromHubAction( /// public record GameEndedFromHubAction(GameSummaryDto Summary); +/// +/// Action dispatched when game state DTO is received from remote SignalR hub. +/// +public record RemoteGameStateUpdatedAction(Koogle.Application.DTOs.GameStateSerializationDto State); + // Error Actions /// diff --git a/src/Koogle.Web/Store/GameState/GameEffects.cs b/src/Koogle.Web/Store/GameState/GameEffects.cs index dba2564..8145386 100644 --- a/src/Koogle.Web/Store/GameState/GameEffects.cs +++ b/src/Koogle.Web/Store/GameState/GameEffects.cs @@ -448,6 +448,15 @@ public class GameEffects dispatcher); } + // Broadcast throw immediately to other clients (before debounced save) + if (state.ActiveGameId.HasValue) + { + await _hubService.BroadcastThrowAsync( + state.ActiveGameId.Value, + currentPlayerId.Value, + afterThrowState.PinsKnocked); + } + // If game is over, skip save (user must confirm end via UI) if (isGameOver) { diff --git a/src/Koogle.Web/Store/GameState/GameReducers.cs b/src/Koogle.Web/Store/GameState/GameReducers.cs index a8134ef..7ed82d3 100644 --- a/src/Koogle.Web/Store/GameState/GameReducers.cs +++ b/src/Koogle.Web/Store/GameState/GameReducers.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using System.Text.Json; using Fluxor; +using Koogle.Application.DTOs; using Koogle.Application.Games; using Koogle.Domain.Enums; using Microsoft.Extensions.Logging; @@ -509,6 +510,52 @@ public static class GameReducers GameModel = action.GameModel }; + /// + /// Handles RemoteGameStateUpdatedAction - updates state from remote SignalR DTO. + /// + [ReducerMethod] + public static GameState OnRemoteGameStateUpdated(GameState state, RemoteGameStateUpdatedAction action) + { + var dto = action.State; + + var throwPanelAfter = new ThrowPanelState + { + IsStarted = dto.ThrowPanelAfter.IsStarted, + Pin1 = (PinStatus)dto.ThrowPanelAfter.Pins[0], + Pin2 = (PinStatus)dto.ThrowPanelAfter.Pins[1], + Pin3 = (PinStatus)dto.ThrowPanelAfter.Pins[2], + Pin4 = (PinStatus)dto.ThrowPanelAfter.Pins[3], + Pin5 = (PinStatus)dto.ThrowPanelAfter.Pins[4], + Pin6 = (PinStatus)dto.ThrowPanelAfter.Pins[5], + Pin7 = (PinStatus)dto.ThrowPanelAfter.Pins[6], + Pin8 = (PinStatus)dto.ThrowPanelAfter.Pins[7], + Pin9 = (PinStatus)dto.ThrowPanelAfter.Pins[8], + ThrowsPerRound = dto.ThrowPanelAfter.ThrowsPerRound, + ThrowCounterPerRound = dto.ThrowPanelAfter.ThrowCounterPerRound, + ThrowMode = (ThrowMode)dto.ThrowPanelAfter.ThrowMode, + TotalThrowCounter = dto.ThrowPanelAfter.TotalThrowCounter, + BellValue = dto.ThrowPanelAfter.BellValue + }; + + var participants = new ParticipantsState + { + PlayerIds = dto.Participants.PlayerIds, + CurrentPlayerIndex = dto.Participants.CurrentPlayerIndex, + Mode = (ParticipantsMode)dto.Participants.Mode + }; + + object? gameModel = dto.GameModel.HasValue + ? (object?)dto.GameModel.Value + : null; + + return state with + { + ThrowPanelAfter = throwPanelAfter, + Participants = participants, + GameModel = gameModel + }; + } + /// /// Handles GameEndedFromHubAction - handles game ended from another client. ///