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
deleted file mode 100644
index 4b99562..0000000
--- a/SlipItIN.slnx
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
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 4b99562..c2d1931 100644
--- a/SlipItIn.slnx
+++ b/SlipItIn.slnx
@@ -1,9 +1,9 @@
-
+
-
+
-
+
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 27c5b59..961d81a 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 @@
-
@@ -100,7 +75,7 @@
-
+
@@ -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">
-
+
@@ -12,6 +14,6 @@
-
+
diff --git a/SlipItIn/Views/GameBoardPage.xaml.cs b/SlipItIn/Views/GameBoardPage.xaml.cs
index db2b97c..22687d1 100644
--- a/SlipItIn/Views/GameBoardPage.xaml.cs
+++ b/SlipItIn/Views/GameBoardPage.xaml.cs
@@ -5,9 +5,23 @@ namespace SlipItIn.Views;
public partial class GameBoardPage : ContentPage
{
+ private readonly GameBoardViewModel _viewModel;
+
public GameBoardPage()
{
InitializeComponent();
- BindingContext = ServiceHelper.GetRequiredService();
+ BindingContext = _viewModel = ServiceHelper.GetRequiredService();
+ }
+
+ protected override void OnNavigatedTo(NavigatedToEventArgs args)
+ {
+ base.OnNavigatedTo(args);
+ _viewModel.StartRoundTimer();
+ }
+
+ protected override void OnNavigatedFrom(NavigatedFromEventArgs args)
+ {
+ base.OnNavigatedFrom(args);
+ _viewModel.StopRoundTimer();
}
}
diff --git a/SlipItIn/Views/LobbyPage.xaml.cs b/SlipItIn/Views/LobbyPage.xaml.cs
index 402380c..392b9ef 100644
--- a/SlipItIn/Views/LobbyPage.xaml.cs
+++ b/SlipItIn/Views/LobbyPage.xaml.cs
@@ -5,9 +5,17 @@ namespace SlipItIn.Views;
public partial class LobbyPage : ContentPage
{
+ private readonly LobbyViewModel _viewModel;
+
public LobbyPage()
{
InitializeComponent();
- BindingContext = ServiceHelper.GetRequiredService();
+ BindingContext = _viewModel = ServiceHelper.GetRequiredService();
+ }
+
+ protected override void OnNavigatedTo(NavigatedToEventArgs args)
+ {
+ base.OnNavigatedTo(args);
+ _viewModel.Activate();
}
}
diff --git a/aspire.config.json b/aspire.config.json
index 9f423e3..e251e18 100644
--- a/aspire.config.json
+++ b/aspire.config.json
@@ -1,5 +1,5 @@
{
"appHost": {
- "path": "SlipItIN.AppHost/SlipItIN.AppHost.csproj"
+ "path": "SlipItIn.AppHost/SlipItIn.AppHost.csproj"
}
}