diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d9b3819 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,26 @@ +name: Build + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: dotnet restore SlipItIn.slnx + + - name: Build + run: dotnet build SlipItIn.slnx --no-restore --configuration Release diff --git a/.gitignore b/.gitignore index 29a8050..69c97e2 100644 --- a/.gitignore +++ b/.gitignore @@ -361,6 +361,8 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd +# Legacy: urspruenglich SQLite-Dateien — die Anwendung nutzt inzwischen PostgreSQL (via Aspire). +# Eintraege bleiben als Sicherheitsnetz, falls lokal noch alte DB-Dateien existieren. /SlipItIn.Server/slipitIn.db /SlipItIn.Server/slipitIn.db-shm /SlipItIn.Server/slipitIn.db-wal diff --git a/README.md b/README.md index f94c6b8..4297439 100644 --- a/README.md +++ b/README.md @@ -1 +1,53 @@ -# SlipItIn \ No newline at end of file +# SlipItIn + +Ein Echtzeit-Multiplayer-Partyspiel: Jeder Spieler erhält Phrasen-Karten und versucht, sie unbemerkt in die laufende Runde zu "slippen". Wer einen fremden Slip erkennt, kann ihn anfechten — bei einer falschen Beschuldigung wandert die Phrase allerdings zum Beschuldiger. + +## Tech-Stack + +- **Client:** .NET MAUI (.NET 10) mit CommunityToolkit.Mvvm und SignalR-Client +- **Server:** ASP.NET Core 10 Web API + SignalR, EF Core mit PostgreSQL +- **Orchestrierung:** .NET Aspire (`SlipItIn.AppHost`) startet Server inklusive PostgreSQL-Container +- **Shared:** Gemeinsame Modelle und DTOs in `SlipItIn.Shared` + +## Voraussetzungen + +- .NET 10 SDK +- Docker oder Podman (für den PostgreSQL-Container, den Aspire bereitstellt) +- MAUI-Workload: `dotnet workload install maui` +- Für den Server allein genügt die Aspire-Workload-Unterstützung des SDKs + +## Ausführen + +```powershell +dotnet run --project SlipItIn.AppHost/SlipItIn.AppHost.csproj +``` + +Der AppHost startet PostgreSQL (Container), wendet EF-Migrationen automatisch an und seedet Testdaten. Die MAUI-App verbindet sich standardmäßig mit `https://localhost:7274` (Android-Emulator: `http://10.0.2.2:5234`). + +## Konfiguration + +| Key | Zweck | +| --- | --- | +| `Jwt:Key` | Signaturschlüssel für JWT (mindestens 32 Zeichen). **Nicht committen** — via User-Secrets oder Umgebungsvariable setzen. In Development ist ein Fallback-Key hinterlegt; in Production bricht der Server ohne konfigurierten Key den Start ab. | +| `Jwt:Issuer` / `Jwt:Audience` | Token-Aussteller bzw. -Empfänger (Defaults: `SlipItInServer` / `SlipItInClient`) | +| `ConnectionStrings:postgresdb` | Wird lokal von Aspire injiziert; muss nicht manuell gesetzt werden | + +## Projektstruktur + +- `SlipItIn/` — MAUI-Client (Views, ViewModels, Services, Offline-Queue) +- `SlipItIn.Server/` — Web API, SignalR-Hub, EF Core, Migrations +- `SlipItIn.Shared/` — Datenbank-Entitäten und SignalR-DTOs +- `SlipItIn.AppHost/` — Aspire-Orchestrierung +- `SlipItIn.ServiceDefaults/` — Geteilte Aspire-Service-Defaults +- `openwiki/` — Generierte Dokumentation (wird automatisch aktualisiert, bitte nicht von Hand editieren) + +## Build + +```powershell +dotnet build SlipItIn.slnx +``` + +## Hinweise + +- Dokumentationssprache: Handgepflegte Dateien (diese README) sind Deutsch, die generierte OpenWiki-Dokumentation ist Englisch. +- `.gitignore` enthält noch Einträge für `slipitIn.db` — ein Überbleibsel aus der ursprünglichen SQLite-Planung. Die Anwendung nutzt PostgreSQL. diff --git a/SlipItIN.slnx b/SlipItIN.slnx index 9874d5e..9e6f1b4 100644 --- a/SlipItIN.slnx +++ b/SlipItIN.slnx @@ -1,10 +1,10 @@ - + - + - + diff --git a/SlipItIn.Server/Controllers/AuthController.cs b/SlipItIn.Server/Controllers/AuthController.cs index e3fe619..a9f1d4e 100644 --- a/SlipItIn.Server/Controllers/AuthController.cs +++ b/SlipItIn.Server/Controllers/AuthController.cs @@ -17,11 +17,13 @@ public class AuthController : ControllerBase { private readonly IDbContextFactory _contextFactory; private readonly IConfiguration _configuration; + private readonly IHostEnvironment _environment; - public AuthController(IDbContextFactory contextFactory, IConfiguration configuration) + public AuthController(IDbContextFactory contextFactory, IConfiguration configuration, IHostEnvironment environment) { _contextFactory = contextFactory; _configuration = configuration; + _environment = environment; } [HttpPost("register")] @@ -30,6 +32,9 @@ public class AuthController : ControllerBase if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) return BadRequest("Username, Email und Passwort sind erforderlich."); + if (request.Password.Length < 8) + return BadRequest("Das Passwort muss mindestens 8 Zeichen lang sein."); + using var context = _contextFactory.CreateDbContext(); var normalizedEmail = request.Email.Trim().ToLowerInvariant(); @@ -86,7 +91,7 @@ public class AuthController : ControllerBase return Ok(new { - UserId = int.Parse(userIdClaim), + UserId = int.TryParse(userIdClaim, out var parsedUserId) ? parsedUserId : 0, Username = username, Email = email }); @@ -94,7 +99,13 @@ public class AuthController : ControllerBase private AuthResponseDto CreateAuthResponse(User user) { - var jwtKey = _configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!"; + var jwtKey = _configuration["Jwt:Key"]; + if (string.IsNullOrWhiteSpace(jwtKey)) + { + if (!_environment.IsDevelopment()) + throw new InvalidOperationException("Jwt:Key ist nicht konfiguriert."); + jwtKey = "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef"; + } var jwtIssuer = _configuration["Jwt:Issuer"] ?? "SlipItInServer"; var jwtAudience = _configuration["Jwt:Audience"] ?? "SlipItInClient"; var expirationMinutes = int.TryParse(_configuration["Jwt:ExpirationMinutes"], out var parsedMinutes) ? parsedMinutes : 1440; diff --git a/SlipItIn.Server/Hubs/GameHub.cs b/SlipItIn.Server/Hubs/GameHub.cs index 5d2d12c..2072514 100644 --- a/SlipItIn.Server/Hubs/GameHub.cs +++ b/SlipItIn.Server/Hubs/GameHub.cs @@ -142,6 +142,7 @@ public class GameHub : Hub } catch (InvalidOperationException ex) { + // Geprüfte Fachfehler mit festen, harmonsierten Texten dürfen an den Client. await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } catch (Exception ex) @@ -242,6 +243,11 @@ public class GameHub : Hub { await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } + catch (InvalidOperationException ex) + { + // Geprüfte Fachfehler (z. B. "Round is not active") dürfen an den Client. + await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); + } catch (Exception ex) { _logger.LogError(ex, "Error submitting slip"); @@ -259,10 +265,16 @@ public class GameHub : Hub var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetCardId); var game = await _gameService.GetGameAsync(gameId); + using var context = _contextFactory.CreateDbContext(); + var challengingPlayer = await context.Players + .Include(p => p.User) + .FirstOrDefaultAsync(p => p.Id == challengingPlayerId); + var challengeDto = new SlipChallengeDto { ChallengeId = challenge.Id, ChallengingPlayerId = challengingPlayerId, + ChallengingPlayerName = challengingPlayer?.User.Username ?? string.Empty, TargetPlayerId = challenge.TargetPlayerId, TargetCardId = targetCardId, Status = challenge.Status.ToString(), @@ -272,7 +284,6 @@ public class GameHub : Hub await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto); // Sende direkte Notification an den beschuldigten Spieler - using var context = _contextFactory.CreateDbContext(); var targetPlayer = await context.Players.FindAsync(challenge.TargetPlayerId); if (targetPlayer?.ConnectionId != null) { @@ -283,6 +294,11 @@ public class GameHub : Hub { await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); } + catch (InvalidOperationException ex) + { + // Geprüfte Fachfehler (z. B. "Card does not belong to the active round") dürfen an den Client. + await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); + } catch (Exception ex) { _logger.LogError(ex, "Error challenging slip"); @@ -372,9 +388,26 @@ public class GameHub : Hub { player.ConnectionId = Context.ConnectionId; await context.SaveChangesAsync(); + + // (Re-)Join der Lobby-Gruppe, damit Group-Broadcasts nach einem Reconnect wieder ankommen. + var lobbyCode = await context.Games + .Where(g => g.Id == player.GameId) + .Select(g => g.LobbyCode) + .FirstAsync(); + await Groups.AddToGroupAsync(Context.ConnectionId, lobbyCode); + + // Der Client bekommt nach einem Reconnect keinen vollständigen Zustand + // mehr aus der Queue gespielt — er muss RequestGameState aufrufen. } } - catch { /* Ignorieren falls nicht authentifiziert */ } + catch (UnauthorizedAccessException) + { + // Nicht authentifiziert — ConnectionId wird nicht gespeichert. + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to register connection for authenticated user"); + } await base.OnConnectedAsync(); } @@ -382,6 +415,23 @@ public class GameHub : Hub public override async Task OnDisconnectedAsync(Exception? exception) { _logger.LogInformation("Client {ConnectionId} disconnected", Context.ConnectionId); + + // ConnectionId zurücksetzen, damit keine Nachrichten an eine tote Verbindung gehen. + try + { + using var context = _contextFactory.CreateDbContext(); + var players = await context.Players + .Where(p => p.ConnectionId == Context.ConnectionId) + .ToListAsync(); + foreach (var player in players) + player.ConnectionId = null; + await context.SaveChangesAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to clear connection id on disconnect"); + } + await base.OnDisconnectedAsync(exception); - } + } } diff --git a/SlipItIn.Server/Program.cs b/SlipItIn.Server/Program.cs index fa5f097..7905a9b 100644 --- a/SlipItIn.Server/Program.cs +++ b/SlipItIn.Server/Program.cs @@ -14,13 +14,27 @@ builder.AddServiceDefaults(); builder.AddNpgsqlDataSource(connectionName: "postgresdb"); -builder.Services.AddDbContextFactory(options => options.UseNpgsql()); +builder.Services.AddDbContextFactory((sp, options) => + options.UseNpgsql(sp.GetRequiredService())); // Services registrieren builder.Services.AddTransient(); // JWT Authentication -var jwtKey = builder.Configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!"; +var jwtKey = builder.Configuration["Jwt:Key"]; +if (string.IsNullOrWhiteSpace(jwtKey) || jwtKey.Length < 32) +{ + if (builder.Environment.IsDevelopment()) + { + jwtKey = "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef"; + } + else + { + throw new InvalidOperationException( + "Jwt:Key ist nicht konfiguriert oder kürzer als 32 Zeichen. " + + "In Production muss ein eigener Schlüssel via Konfiguration/Umgebungsvariable gesetzt werden."); + } +} var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "SlipItInServer"; var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "SlipItInClient"; diff --git a/SlipItIn.Server/Services/GameService.cs b/SlipItIn.Server/Services/GameService.cs index a7f55de..8b12fe4 100644 --- a/SlipItIn.Server/Services/GameService.cs +++ b/SlipItIn.Server/Services/GameService.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using SlipItIn.Server.Data; using SlipItIn.Shared.DTOs; using SlipItIn.Shared.Models; +using System.Security.Cryptography; namespace SlipItIn.Server.Services; @@ -21,7 +22,7 @@ public class GameService : IGameService using var context = _contextFactory.CreateDbContext(); var host = await context.Users.FirstOrDefaultAsync(u => u.Id == hostUserId) ?? throw new InvalidOperationException("Host not found"); - var lobbyCode = GenerateLobbyCode(); + var lobbyCode = await GenerateUniqueLobbyCodeAsync(context); var game = new Game { LobbyCode = lobbyCode, @@ -70,7 +71,16 @@ public class GameService : IGameService { using var context = _contextFactory.CreateDbContext(); - var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found"); + var game = await context.Games + .Include(g => g.Players) + .FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found"); + + if (game.Status != GameStatus.Lobby) + throw new InvalidOperationException("Game has already started or ended"); + + if (game.Players.Count < 2) + throw new InvalidOperationException("At least 2 players are required to start the game"); + game.Status = GameStatus.InProgress; game.StartedAt = DateTime.UtcNow; @@ -85,12 +95,16 @@ public class GameService : IGameService { using var context = _contextFactory.CreateDbContext(); - var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId) ?? throw new InvalidOperationException("Player not found"); + var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found"); + if (game.Status != GameStatus.Lobby) + throw new InvalidOperationException("Game is not in lobby status"); + + var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId && p.GameId == gameId) + ?? throw new InvalidOperationException("Player not found in this game"); player.IsReady = true; await context.SaveChangesAsync(); - return await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) - ?? throw new InvalidOperationException("Game not found"); + return game; } public async Task> DealCardsAsync(int gameId) @@ -108,7 +122,7 @@ public class GameService : IGameService foreach (var player in players) { - var selectedPhrases = allPhrases.OrderBy(_ => Random.Shared.Next()).Take(5); + var selectedPhrases = allPhrases.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue)).Take(5); foreach (var phrase in selectedPhrases) { @@ -125,6 +139,7 @@ public class GameService : IGameService } currentRound.Status = RoundStatus.Active; + currentRound.StartedAt = DateTime.UtcNow; await context.SaveChangesAsync(); return dealtCards; @@ -134,10 +149,18 @@ public class GameService : IGameService { using var context = _contextFactory.CreateDbContext(); + var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found"); + if (game.Status != GameStatus.InProgress) + throw new InvalidOperationException("Game is not in progress"); + var card = await context.PlayerCards - .Include(pc => pc.Player) + .Include(pc => pc.GameRound) .FirstOrDefaultAsync(pc => pc.Id == cardId) ?? throw new InvalidOperationException("Card not found"); + if (card.PlayerId != playerId) throw new UnauthorizedAccessException("Card does not belong to player"); + if (card.GameRound.GameId != gameId) throw new InvalidOperationException("Card does not belong to this game"); + if (card.GameRound.Status != RoundStatus.Active) throw new InvalidOperationException("Round is not active"); + if (card.IsUsed) throw new InvalidOperationException("Card has already been used"); card.IsUsed = true; await context.SaveChangesAsync(); @@ -149,13 +172,24 @@ public class GameService : IGameService { using var context = _contextFactory.CreateDbContext(); - var targetCard = await context.PlayerCards.FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found"); - var currentRound = await context.GameRounds .Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Active) .FirstOrDefaultAsync() ?? throw new InvalidOperationException("No active round found"); - if (currentRound == null) throw new InvalidOperationException("No active round found"); + var targetCard = await context.PlayerCards + .FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found"); + + if (targetCard.GameRoundId != currentRound.Id) + throw new InvalidOperationException("Card does not belong to the active round"); + if (targetCard.PlayerId == challengingPlayerId) + throw new InvalidOperationException("Cannot challenge your own card"); + if (!targetCard.IsUsed) + throw new InvalidOperationException("Card has not been played yet"); + + var pendingExists = await context.SlipChallenges + .AnyAsync(c => c.TargetCardId == targetCardId && c.Status == ChallengeStatus.Pending); + if (pendingExists) + throw new InvalidOperationException("A challenge for this card is already pending"); var challenge = new SlipChallenge { @@ -179,19 +213,30 @@ public class GameService : IGameService var challenge = await context.SlipChallenges .Include(sc => sc.GameRound) .Include(sc => sc.TargetCard) + .Include(sc => sc.ChallengingPlayer) + .Include(sc => sc.TargetPlayer) .FirstOrDefaultAsync(sc => sc.Id == challengeId) ?? throw new InvalidOperationException("Challenge not found"); - // approved = Beschuldigung stimmt (Phrase war falsch) - // rejected = Beschuldigung stimmt nicht (Phrase war richtig) + if (challenge.Status != ChallengeStatus.Pending) + throw new InvalidOperationException("Challenge has already been resolved"); - if (!approved) + // approved = Beschuldigung stimmt (Phrase war ein unberechtigtes SlipIn des Beschuldigten) + // rejected = Beschuldigung stimmt nicht (Phrase wurde regelkonform gespielt) + + if (approved) { - // FALSCHE BESCHULDIGUNG: Beschuldiger erhält die Phrase des Beschuldigten - var targetCard = challenge.TargetCard; - targetCard.PlayerId = challenge.ChallengingPlayerId; - context.PlayerCards.Update(targetCard); + // Berechtigte Beschuldigung: Beschuldigter muss die Phrase an den Beschuldiger abgeben + challenge.TargetCard.PlayerId = challenge.ChallengingPlayerId; + challenge.ChallengingPlayer.Score += 1; + challenge.TargetPlayer.FailedSlips += 1; + } + else + { + // Falsche Beschuldigung: Phrase bleibt beim Beschuldigten + challenge.TargetPlayer.SuccessfulSlips += 1; + challenge.TargetPlayer.Score += 1; + challenge.ChallengingPlayer.FailedSlips += 1; } - // Wenn approved: Phrase bleibt bei Beschuldigtem (= Phrase war tatsächlich ein SlipIn) challenge.Status = approved ? ChallengeStatus.Approved : ChallengeStatus.Rejected; challenge.ResolvedAt = DateTime.UtcNow; @@ -218,7 +263,7 @@ public class GameService : IGameService Username = p.User.Username, Score = p.Score, IsReady = p.IsReady, - CardCount = p.Cards.Count // Nur Anzahl, nicht die Karten selbst! + CardCount = p.Cards.Count(c => !c.IsUsed) // Nur Anzahl, nicht die Karten selbst! }).ToList(); var currentRound = game.Rounds @@ -249,9 +294,17 @@ public class GameService : IGameService { using var context = _contextFactory.CreateDbContext(); + // Nur Karten der aktuell aktiven Runde zurückgeben — sonst wächst die Hand + // über Runden hinweg unbegrenzt und enthält veraltete Karten. + var activeRoundIds = await context.GameRounds + .Where(gr => gr.Status == RoundStatus.Active && + gr.Game.Players.Any(p => p.Id == playerId)) + .Select(gr => gr.Id) + .ToListAsync(); + var cards = await context.PlayerCards .Include(pc => pc.Phrase) - .Where(pc => pc.PlayerId == playerId) + .Where(pc => pc.PlayerId == playerId && activeRoundIds.Contains(pc.GameRoundId)) .Select(pc => new PlayerCardDto { CardId = pc.Id, @@ -275,11 +328,25 @@ public class GameService : IGameService ?? throw new InvalidOperationException("Game not found"); } - private string GenerateLobbyCode() + private static string GenerateLobbyCode() { const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - var code = new string([.. Enumerable.Range(0, 6).Select(_ => chars[Random.Shared.Next(chars.Length)])]); + var buffer = new char[6]; + for (var i = 0; i < buffer.Length; i++) + buffer[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)]; + return new string(buffer); + } - return code; + private static async Task GenerateUniqueLobbyCodeAsync(SlipItInDbContext context, int maxAttempts = 10) + { + for (var attempt = 0; attempt < maxAttempts; attempt++) + { + var code = GenerateLobbyCode(); + var exists = await context.Games.AnyAsync(g => g.LobbyCode == code); + if (!exists) + return code; + } + + throw new InvalidOperationException("Could not generate a unique lobby code"); } } diff --git a/SlipItIn.Server/appsettings.Development.json b/SlipItIn.Server/appsettings.Development.json index 0c208ae..73651c2 100644 --- a/SlipItIn.Server/appsettings.Development.json +++ b/SlipItIn.Server/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Jwt": { + "Key": "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef" } } diff --git a/SlipItIn.Server/appsettings.json b/SlipItIn.Server/appsettings.json index 48d5f33..d310648 100644 --- a/SlipItIn.Server/appsettings.json +++ b/SlipItIn.Server/appsettings.json @@ -7,11 +7,7 @@ } }, "AllowedHosts": "*", - "ConnectionStrings": { - "postgresdb": "Server=(localdb)\\postgres;Database=postgresdb" - }, "Jwt": { - "Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!", "Issuer": "SlipItInServer", "Audience": "SlipItInClient", "ExpirationMinutes": 1440 diff --git a/SlipItIn.slnx b/SlipItIn.slnx index 9874d5e..9e6f1b4 100644 --- a/SlipItIn.slnx +++ b/SlipItIn.slnx @@ -1,10 +1,10 @@ - + - + - + diff --git a/SlipItIn/App.xaml.cs b/SlipItIn/App.xaml.cs index 328a43d..fb679d1 100644 --- a/SlipItIn/App.xaml.cs +++ b/SlipItIn/App.xaml.cs @@ -5,6 +5,8 @@ namespace SlipItIn; public partial class App : Application { + private readonly SemaphoreSlim _startupLock = new(1, 1); + public App() { InitializeComponent(); @@ -21,15 +23,25 @@ public partial class App : Application window.Resumed += async (_, _) => { - var gameStateService = ServiceHelper.GetRequiredService(); - await gameStateService.ResyncAsync(); + try + { + var gameStateService = ServiceHelper.GetRequiredService(); + await gameStateService.ResyncAsync(); + } + catch + { + // Resume darf nicht abstürzen; Sync-Status wurde bereits gemeldet. + } }; return window; } - private static async Task InitializeSafeAsync() + private async Task InitializeSafeAsync() { + if (!await _startupLock.WaitAsync(0)) + return; + try { var authSession = ServiceHelper.GetRequiredService(); @@ -43,8 +55,14 @@ public partial class App : Application if (string.IsNullOrWhiteSpace(authSession.AccessToken)) return; - var isSessionValid = await apiService.ValidateSessionAsync(); - if (!isSessionValid) + var sessionResult = await apiService.ValidateSessionAsync(); + if (sessionResult == SessionValidationResult.NetworkError) + { + // Netzwerkfehler darf die lokale Session nicht zerstören. + return; + } + + if (sessionResult == SessionValidationResult.Invalid) { await authSession.ClearSessionAsync(); await gameStateService.ClearLocalGameDataAsync(); @@ -60,5 +78,9 @@ public partial class App : Application { // Start darf nicht abstürzen, wenn Session/Netzwerk fehlschlägt. } + finally + { + _startupLock.Release(); + } } } diff --git a/SlipItIn/Models/QueuedGameAction.cs b/SlipItIn/Models/QueuedGameAction.cs index d13cb8f..e20b306 100644 --- a/SlipItIn/Models/QueuedGameAction.cs +++ b/SlipItIn/Models/QueuedGameAction.cs @@ -5,4 +5,5 @@ public class QueuedGameAction public string Method { get; set; } = string.Empty; public string ArgumentsJson { get; set; } = "[]"; public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + public int RetryCount { get; set; } } diff --git a/SlipItIn/Services/ApiService.cs b/SlipItIn/Services/ApiService.cs index d8273b3..949eb9d 100644 --- a/SlipItIn/Services/ApiService.cs +++ b/SlipItIn/Services/ApiService.cs @@ -28,20 +28,26 @@ public class ApiService : IApiService public Task LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default) => PostAuthAsync("api/auth/login", request, cancellationToken); - public async Task ValidateSessionAsync(CancellationToken cancellationToken = default) + public async Task ValidateSessionAsync(CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(_authSession.AccessToken)) - return false; + return SessionValidationResult.Invalid; try { AttachAuthHeader(); var response = await _httpClient.GetAsync("api/auth/me", cancellationToken); - return response.IsSuccessStatusCode; + return response.IsSuccessStatusCode + ? SessionValidationResult.Valid + : SessionValidationResult.Invalid; } - catch + catch (HttpRequestException) { - return false; + return SessionValidationResult.NetworkError; + } + catch (TaskCanceledException) + { + return SessionValidationResult.NetworkError; } } diff --git a/SlipItIn/Services/GameStateService.cs b/SlipItIn/Services/GameStateService.cs index 8dbfb6c..e689180 100644 --- a/SlipItIn/Services/GameStateService.cs +++ b/SlipItIn/Services/GameStateService.cs @@ -28,21 +28,21 @@ public class GameStateService : IGameStateService _signalR.LobbyCreated += (_, value) => _messenger.Send(new LobbyCreatedMessage(value)); - _signalR.GameStateUpdated += async (_, state) => + _signalR.GameStateUpdated += (_, state) => RunSafeBridge(async () => { CurrentGameState = state; await _localStorage.SaveGameStateAsync(state); _messenger.Send(new GameStateChangedMessage(state)); - }; + }); _signalR.GameStarted += (_, gameId) => _messenger.Send(new GameStartedMessage(gameId)); - _signalR.PlayerHandUpdated += async (_, hand) => + _signalR.PlayerHandUpdated += (_, hand) => RunSafeBridge(async () => { CurrentPlayerHand = hand; await _localStorage.SavePlayerHandAsync(hand); _messenger.Send(new PlayerHandChangedMessage(hand)); - }; + }); _signalR.ChallengeReceived += (_, challenge) => { @@ -51,21 +51,26 @@ public class GameStateService : IGameStateService }; _signalR.ErrorReceived += (_, error) => _messenger.Send(new ErrorOccurredMessage(error)); - _signalR.ConnectionStateChanged += async (_, connected) => + _signalR.ConnectionStateChanged += (_, connected) => RunSafeBridge(async () => { _messenger.Send(new ConnectionStateChangedMessage(connected)); if (!connected) return; - try - { - await ResyncAsync(); - } - catch (Exception ex) - { - _messenger.Send(new ErrorOccurredMessage($"Resync fehlgeschlagen: {ex.Message}")); - } - }; + await ResyncAsync(); + }); + } + + private async void RunSafeBridge(Func action) + { + try + { + await action(); + } + catch (Exception ex) + { + _messenger.Send(new ErrorOccurredMessage($"Hintergrundfehler: {ex.Message}")); + } } public async Task InitializeAsync() @@ -153,12 +158,16 @@ public class GameStateService : IGameStateService PublishSyncState(false, "Bereit", 0); } + private const int MaxQueueRetries = 3; + private async Task FlushQueuedActionsCoreAsync() { if (!_signalR.IsConnected || _queuedActions.Count == 0) return; var snapshot = _queuedActions.ToList(); + var poisoned = new List(); + foreach (var action in snapshot) { try @@ -169,11 +178,23 @@ public class GameStateService : IGameStateService } catch (Exception ex) { - _messenger.Send(new ErrorOccurredMessage($"Queue-Aktion fehlgeschlagen ({action.Method}): {ex.Message}")); - break; + action.RetryCount++; + if (action.RetryCount >= MaxQueueRetries) + { + poisoned.Add(action); + _messenger.Send(new ErrorOccurredMessage($"Queue-Aktion verworfen ({action.Method}): {ex.Message}")); + } + else + { + _messenger.Send(new ErrorOccurredMessage($"Queue-Aktion fehlgeschlagen ({action.Method}): {ex.Message}")); + break; // Bei transienten Fehlern den Rest der Queue nicht blockieren + } } } + foreach (var item in poisoned) + _queuedActions.Remove(item); + await _localStorage.SaveQueuedActionsAsync(_queuedActions); } diff --git a/SlipItIn/Services/Interfaces/IApiService.cs b/SlipItIn/Services/Interfaces/IApiService.cs index d9ee135..099d7ca 100644 --- a/SlipItIn/Services/Interfaces/IApiService.cs +++ b/SlipItIn/Services/Interfaces/IApiService.cs @@ -2,10 +2,17 @@ using SlipItIn.Shared.DTOs; namespace SlipItIn.Services.Interfaces; +public enum SessionValidationResult +{ + Valid, + Invalid, + NetworkError +} + public interface IApiService { Task RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default); Task LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default); - Task ValidateSessionAsync(CancellationToken cancellationToken = default); + Task ValidateSessionAsync(CancellationToken cancellationToken = default); Task LogoutAsync(); } diff --git a/SlipItIn/Services/SignalRService.cs b/SlipItIn/Services/SignalRService.cs index 9060cca..d5cf3fd 100644 --- a/SlipItIn/Services/SignalRService.cs +++ b/SlipItIn/Services/SignalRService.cs @@ -1,21 +1,25 @@ using System.Text.Json; using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.DependencyInjection; using SlipItIn.Models; using SlipItIn.Services.Interfaces; using SlipItIn.Shared.DTOs; namespace SlipItIn.Services; -public class SignalRService : ISignalRService +public class SignalRService : ISignalRService, IAsyncDisposable { private readonly IAppConfigurationService _configuration; private readonly IAuthSessionService _authSession; + private readonly IServiceProvider _serviceProvider; + private readonly SemaphoreSlim _connectLock = new(1, 1); private HubConnection? _hubConnection; - public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession) + public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession, IServiceProvider serviceProvider) { _configuration = configuration; _authSession = authSession; + _serviceProvider = serviceProvider; } public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected; @@ -33,36 +37,76 @@ public class SignalRService : ISignalRService if (IsConnected) return true; - EnsureConnection(token); - - if (_hubConnection is null) - return false; - - ConnectionStateChanged?.Invoke(this, false); - + await _connectLock.WaitAsync(cancellationToken); try { - await _hubConnection.StartAsync(cancellationToken); - ConnectionStateChanged?.Invoke(this, true); - return true; - } - catch (Exception ex) - { + if (IsConnected) + return true; + + // Bestehende (möglicherweise tote) Verbindung vollständig entsorgen, + // damit der AccessTokenProvider keinen veralteten Token-Closure verwendet. + if (_hubConnection is not null) + { + try + { + if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting) + await _hubConnection.StopAsync(cancellationToken); + } + catch + { + // Best effort; Dispose folgt trotzdem. + } + + await _hubConnection.DisposeAsync(); + _hubConnection = null; + } + + EnsureConnection(token); + + if (_hubConnection is null) + return false; + ConnectionStateChanged?.Invoke(this, false); - ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}"); - return false; + + try + { + await _hubConnection.StartAsync(cancellationToken); + ConnectionStateChanged?.Invoke(this, true); + return true; + } + catch (Exception ex) + { + ConnectionStateChanged?.Invoke(this, false); + ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}"); + return false; + } + } + finally + { + _connectLock.Release(); } } public async Task DisconnectAsync() { - if (_hubConnection is null) - return; + await _connectLock.WaitAsync(); + try + { + if (_hubConnection is null) + return; - if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting) - await _hubConnection.StopAsync(); + if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting) + await _hubConnection.StopAsync(); - ConnectionStateChanged?.Invoke(this, false); + await _hubConnection.DisposeAsync(); + _hubConnection = null; + + ConnectionStateChanged?.Invoke(this, false); + } + finally + { + _connectLock.Release(); + } } public Task CreateLobbyAsync() => InvokeAsync("CreateLobby"); @@ -87,7 +131,7 @@ public class SignalRService : ISignalRService public async Task SendQueuedActionAsync(QueuedGameAction action) { if (_hubConnection is null) - return; + throw new InvalidOperationException("Keine aktive SignalR-Verbindung."); object?[] args; try @@ -95,11 +139,11 @@ public class SignalRService : ISignalRService var doc = JsonDocument.Parse(action.ArgumentsJson); args = doc.RootElement.ValueKind == JsonValueKind.Array ? doc.RootElement.EnumerateArray().Select(ToObject).ToArray() - : []; + : throw new InvalidOperationException($"Ungültige Queue-Aktion: {action.ArgumentsJson}"); } - catch + catch (JsonException ex) { - args = []; + throw new InvalidOperationException($"Queue-Aktion '{action.Method}' ist beschädigt.", ex); } await InvokeAsync(action.Method, args); @@ -113,7 +157,8 @@ public class SignalRService : ISignalRService _hubConnection = new HubConnectionBuilder() .WithUrl(_configuration.HubUrl, options => { - options.AccessTokenProvider = () => Task.FromResult(_authSession.AccessToken ?? token); + // Token immer zur Laufzeit aus der Session lesen — nie den Startwert cachen. + options.AccessTokenProvider = () => Task.FromResult(_authSession.AccessToken); }) .WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)]) .Build(); @@ -155,6 +200,13 @@ public class SignalRService : ISignalRService _hubConnection.On("PlayerHandUpdated", dto => PlayerHandUpdated?.Invoke(this, dto)); _hubConnection.On("ChallengeReceived", dto => ChallengeReceived?.Invoke(this, dto)); + // Server-Broadcasts nach Slip/Challenge/Resolve, damit die UI auch ohne + // vollständiges GameState-Update reagieren kann. + _hubConnection.On("SlipSubmitted", _ => RequestCurrentGameStateSafe()); + _hubConnection.On("SlipChallenged", _ => RequestCurrentGameStateSafe()); + _hubConnection.On("ChallengeResolved", _ => RequestCurrentGameStateSafe()); + _hubConnection.On("CardsDealt", _ => RequestCurrentGameStateSafe()); + _hubConnection.On("Error", payload => { var message = payload.TryGetProperty("message", out var msgElement) @@ -164,6 +216,26 @@ public class SignalRService : ISignalRService }); } + private void RequestCurrentGameStateSafe() + { + _ = Task.Run(async () => + { + try + { + // Lazy über ServiceProvider auflösen, um eine zirkuläre Abhängigkeit + // SignalRService ↔ GameStateService zu vermeiden. + var gameStateService = _serviceProvider.GetService(); + var gameState = gameStateService?.CurrentGameState; + if (gameState?.GameId > 0 && IsConnected) + await RequestGameStateAsync(gameState.GameId); + } + catch + { + // Hintergrund-Refresh darf nicht abstürzen. + } + }); + } + private async Task InvokeAsync(string method, params object?[] args) { if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected) @@ -186,4 +258,10 @@ public class SignalRService : ISignalRService _ => element.GetRawText() }; } + + public async ValueTask DisposeAsync() + { + if (_hubConnection is not null) + await _hubConnection.DisposeAsync(); + } } diff --git a/SlipItIn/SlipItIn.csproj b/SlipItIn/SlipItIn.csproj index 9ec44f0..31c1665 100644 --- a/SlipItIn/SlipItIn.csproj +++ b/SlipItIn/SlipItIn.csproj @@ -48,35 +48,11 @@ 10.0.17763.0 - + SlipItIn - - SlipItIn - - - - SlipItIn - - - - SlipItIn - - - - SlipItIn - - - - SlipItIn - - - - SlipItIn - - - + SlipItIn @@ -89,7 +65,6 @@ - @@ -110,9 +85,6 @@ - - MSBuild:Compile - MSBuild:Compile diff --git a/SlipItIn/ViewModels/GameBoardViewModel.cs b/SlipItIn/ViewModels/GameBoardViewModel.cs index bdd37fe..53e526d 100644 --- a/SlipItIn/ViewModels/GameBoardViewModel.cs +++ b/SlipItIn/ViewModels/GameBoardViewModel.cs @@ -19,6 +19,8 @@ public partial class GameBoardViewModel : BaseViewModel, private readonly ISignalRService _signalR; private readonly IGameStateService _gameStateService; private readonly IAuthSessionService _authSession; + private readonly IDispatcherTimer? _roundTimer; + private DateTime _lastStateUtc = DateTime.UtcNow; [ObservableProperty] private int gameId; @@ -58,6 +60,26 @@ public partial class GameBoardViewModel : BaseViewModel, IsConnected = _signalR.IsConnected; IsActive = true; + + if (Application.Current?.Dispatcher is { } dispatcher) + { + _roundTimer = dispatcher.CreateTimer(); + _roundTimer.Interval = TimeSpan.FromSeconds(1); + _roundTimer.Tick += OnRoundTimerTick; + } + } + + private void OnRoundTimerTick(object? sender, EventArgs e) + { + if (RoundTimeRemaining <= 0) + return; + + var elapsed = (int)(DateTime.UtcNow - _lastStateUtc).TotalSeconds; + if (elapsed > 0) + { + RoundTimeRemaining = Math.Max(0, RoundTimeRemaining - elapsed); + _lastStateUtc = DateTime.UtcNow; + } } [RelayCommand] @@ -123,9 +145,22 @@ public partial class GameBoardViewModel : BaseViewModel, if (PendingChallenge is null) return; - await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, true); + var challengeId = PendingChallenge.ChallengeId; PendingChallenge = null; IsChallengeVisible = false; + + await RunSafeAsync(async () => + { + if (_signalR.IsConnected) + { + await _signalR.ResolveChallengeAsync(challengeId, true); + } + else + { + await _gameStateService.EnqueueActionAsync("ResolveChallenge", challengeId, true); + StatusMessage = "Offline: Antwort wurde zwischengespeichert."; + } + }); } [RelayCommand] @@ -134,9 +169,22 @@ public partial class GameBoardViewModel : BaseViewModel, if (PendingChallenge is null) return; - await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, false); + var challengeId = PendingChallenge.ChallengeId; PendingChallenge = null; IsChallengeVisible = false; + + await RunSafeAsync(async () => + { + if (_signalR.IsConnected) + { + await _signalR.ResolveChallengeAsync(challengeId, false); + } + else + { + await _gameStateService.EnqueueActionAsync("ResolveChallenge", challengeId, false); + StatusMessage = "Offline: Antwort wurde zwischengespeichert."; + } + }); } public void Receive(GameStateChangedMessage message) @@ -146,6 +194,7 @@ public partial class GameBoardViewModel : BaseViewModel, GameId = message.Value.GameId; CurrentRound = message.Value.CurrentRound; RoundTimeRemaining = message.Value.RoundTimeRemaining; + _lastStateUtc = DateTime.UtcNow; var me = _authSession.CurrentUser?.Username; var others = message.Value.Players @@ -170,27 +219,36 @@ public partial class GameBoardViewModel : BaseViewModel, public void Receive(ChallengeReceivedMessage message) { - PendingChallenge = message.Value; - IsChallengeVisible = true; + MainThread.BeginInvokeOnMainThread(() => + { + PendingChallenge = message.Value; + IsChallengeVisible = true; + }); } public void Receive(ErrorOccurredMessage message) { - StatusMessage = message.Value; + MainThread.BeginInvokeOnMainThread(() => StatusMessage = message.Value); } public void Receive(SyncStateChangedMessage message) { - IsSyncing = message.Value.IsSyncing; - SyncStatusText = message.Value.StatusText; - PendingSyncActions = message.Value.PendingActions; + MainThread.BeginInvokeOnMainThread(() => + { + IsSyncing = message.Value.IsSyncing; + SyncStatusText = message.Value.StatusText; + PendingSyncActions = message.Value.PendingActions; + }); } public void Receive(ConnectionStateChangedMessage message) { - IsConnected = message.Value; + MainThread.BeginInvokeOnMainThread(() => IsConnected = message.Value); } + public void StartRoundTimer() => _roundTimer?.Start(); + public void StopRoundTimer() => _roundTimer?.Stop(); + private int GetCurrentPlayerId() { var username = _authSession.CurrentUser?.Username; diff --git a/SlipItIn/ViewModels/LobbyViewModel.cs b/SlipItIn/ViewModels/LobbyViewModel.cs index e601597..12b26a0 100644 --- a/SlipItIn/ViewModels/LobbyViewModel.cs +++ b/SlipItIn/ViewModels/LobbyViewModel.cs @@ -55,6 +55,16 @@ public partial class LobbyViewModel : BaseViewModel, IsActive = true; } + /// + /// Reaktiviert das ViewModel nach einem Logout (Singleton-Lebensdauer). + /// Wird von LobbyPage.OnNavigatedTo aufgerufen. + /// + public void Activate() + { + IsConnected = _signalR.IsConnected; + IsActive = true; + } + [RelayCommand] private async Task CreateLobbyAsync() { @@ -105,6 +115,7 @@ public partial class LobbyViewModel : BaseViewModel, { await RunSafeAsync(async () => { + IsActive = false; await _signalR.DisconnectAsync(); await _apiService.LogoutAsync(); await _gameStateService.ClearLocalGameDataAsync(); @@ -134,26 +145,39 @@ public partial class LobbyViewModel : BaseViewModel, }); } - public async void Receive(GameStartedMessage message) + public void Receive(GameStartedMessage message) { - await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync(nameof(Views.GameBoardPage))); + MainThread.BeginInvokeOnMainThread(() => + { + try + { + Shell.Current.GoToAsync(nameof(Views.GameBoardPage)); + } + catch (Exception ex) + { + StatusMessage = $"Navigation fehlgeschlagen: {ex.Message}"; + } + }); } public void Receive(ErrorOccurredMessage message) { - StatusMessage = message.Value; + MainThread.BeginInvokeOnMainThread(() => StatusMessage = message.Value); } public void Receive(ConnectionStateChangedMessage message) { - IsConnected = message.Value; + MainThread.BeginInvokeOnMainThread(() => IsConnected = message.Value); } public void Receive(SyncStateChangedMessage message) { - IsSyncing = message.Value.IsSyncing; - SyncStatusText = message.Value.StatusText; - PendingSyncActions = message.Value.PendingActions; + MainThread.BeginInvokeOnMainThread(() => + { + IsSyncing = message.Value.IsSyncing; + SyncStatusText = message.Value.StatusText; + PendingSyncActions = message.Value.PendingActions; + }); } private int GetCurrentPlayerId() diff --git a/SlipItIn/ViewModels/LoginViewModel.cs b/SlipItIn/ViewModels/LoginViewModel.cs index 887aecb..728afda 100644 --- a/SlipItIn/ViewModels/LoginViewModel.cs +++ b/SlipItIn/ViewModels/LoginViewModel.cs @@ -58,8 +58,11 @@ public partial class LoginViewModel : BaseViewModel if (!_authSession.IsAuthenticated) return; - var isSessionValid = await _apiService.ValidateSessionAsync(); - if (!isSessionValid) + var sessionResult = await _apiService.ValidateSessionAsync(); + if (sessionResult == SessionValidationResult.NetworkError) + return; + + if (sessionResult == SessionValidationResult.Invalid) { await _authSession.ClearSessionAsync(); await _gameStateService.ClearLocalGameDataAsync(); diff --git a/SlipItIn/ViewModels/RegisterViewModel.cs b/SlipItIn/ViewModels/RegisterViewModel.cs index 572807f..c859099 100644 --- a/SlipItIn/ViewModels/RegisterViewModel.cs +++ b/SlipItIn/ViewModels/RegisterViewModel.cs @@ -44,7 +44,9 @@ public partial class RegisterViewModel : BaseViewModel }); await _authSession.SetSessionAsync(response); - await _signalR.ConnectAsync(response.Token); + var connected = await _signalR.ConnectAsync(response.Token); + if (!connected) + throw new InvalidOperationException("Verbindung zum Spielserver fehlgeschlagen."); await Shell.Current.GoToAsync(nameof(LobbyPage)); }, "Registrierung läuft..."); } diff --git a/SlipItIn/Views/ChallengeNotificationOverlay.xaml b/SlipItIn/Views/ChallengeNotificationOverlay.xaml index 434d57f..1bf75a9 100644 --- a/SlipItIn/Views/ChallengeNotificationOverlay.xaml +++ b/SlipItIn/Views/ChallengeNotificationOverlay.xaml @@ -1,9 +1,11 @@ + xmlns:vm="clr-namespace:SlipItIn.ViewModels" + x:Class="SlipItIn.Views.ChallengeNotificationOverlay" + x:DataType="vm:GameBoardViewModel"> - +