Tech-Debt-Cleanup: Doku, Security-Hardening, Server-Validierung, Client-Stabilitaet
Some checks failed
Build / build (pull_request) Has been cancelled

Doku/Repo:
- README.md mit Setup, Run-Anleitung, Konfiguration und Sprachpolicy neu geschrieben
- appsettings.json: committed JWT-Key und irreführende LocalDB-Connection-String entfernt
- appsettings.Development.json: expliziter Dev-Only JWT-Key hinterlegt
- build.yml: Build-CI für master/PRs hinzugefügt
- slnx/aspire.config.json: Pfad-Casing korrigiert (Linux-kompatibel)
- .gitignore: Kommentar zu den Legacy-SQLite-Einträgen

Server:
- Jwt:Key ist in Production Pflicht (Fail-fast statt Fallback-Secret)
- Passwort-Mindestlänge 8 Zeichen bei Registrierung
- StartGame: nur aus Lobby, mindestens 2 Spieler
- SetPlayerReady: gameId/Status geprüft
- SubmitSlip: Game-/Round-Status, Round-Membership, IsUsed validiert
- CreateChallenge: Round-Bindung, kein Self-Challenge, nur gespielte Karten, keine Doppel-Challenges
- ResolveChallenge: Pending-Guard, Scoring (Score/SuccessfulSlips/FailedSlips), Approved-Transfer gefixt
- GetPlayerHand: nur Karten der aktiven Runde
- RoundTimeRemaining: StartedAt wird bei Round-Aktivierung gesetzt
- Lobby-Code: crypto-random mit Kollisions-Retry
- EF: explizite NpgsqlDataSource-Auflösung aus DI
- Hub: Reconnect-Gruppenbeitritt, ConnectionId-Cleanup beim Disconnect, ChallengingPlayerName gesetzt, SlipChallenged/ChallengeResolved konsistent

Client:
- Offline-Start löscht Session nicht mehr (NetworkError vs. Invalid unterschieden)
- ResolveChallenge geht durch die Offline-Queue (kein Crash mehr bei Disconnect)
- Queue: Poison-Entries werden nach 3 Retries verworfen statt die Queue ewig zu blockieren
- SignalR: Verbindung wird sauber disposed/rebuildet, Token immer zur Laufzeit gelesen, Connect-Lock gegen parallele Starts
- ViewModels: Thread-Marshaling in allen Receive-Handlern, kein async void mehr
- LobbyViewModel: Logout deaktiviert den Singleton (kein stale-Navigation mehr), Reaktivierung via Activate()
- GameBoard: lokaler 1s-Timer für RoundTimeRemaining
- Overlay: x:DataType für kompilierte Bindings, Frame→Border
- csproj: Preview-Logging-Package auf 10.0.10, Template-Cruft entfernt
This commit is contained in:
Tim Krampitz
2026-08-22 19:37:54 +02:00
parent b1435b2c1e
commit af6528ee68
26 changed files with 588 additions and 158 deletions

26
.github/workflows/build.yml vendored Normal file
View File

@@ -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

2
.gitignore vendored
View File

@@ -361,6 +361,8 @@ MigrationBackup/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd 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
/SlipItIn.Server/slipitIn.db-shm /SlipItIn.Server/slipitIn.db-shm
/SlipItIn.Server/slipitIn.db-wal /SlipItIn.Server/slipitIn.db-wal

View File

@@ -1 +1,53 @@
# SlipItIn # 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.

View File

@@ -1,9 +0,0 @@
<Solution>
<Project Path="SlipItIN.AppHost/SlipItIn.AppHost.csproj" />
<Project Path="SlipItIn.Server/SlipItIn.Server.csproj" />
<Project Path="SlipItIN.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
<Project Path="SlipItIn.Shared/SlipItIn.Shared.csproj" />
<Project Path="SlipItIN/SlipItIn.csproj">
<Deploy Solution="Debug|*" />
</Project>
</Solution>

View File

@@ -17,11 +17,13 @@ public class AuthController : ControllerBase
{ {
private readonly IDbContextFactory<SlipItInDbContext> _contextFactory; private readonly IDbContextFactory<SlipItInDbContext> _contextFactory;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IHostEnvironment _environment;
public AuthController(IDbContextFactory<SlipItInDbContext> contextFactory, IConfiguration configuration) public AuthController(IDbContextFactory<SlipItInDbContext> contextFactory, IConfiguration configuration, IHostEnvironment environment)
{ {
_contextFactory = contextFactory; _contextFactory = contextFactory;
_configuration = configuration; _configuration = configuration;
_environment = environment;
} }
[HttpPost("register")] [HttpPost("register")]
@@ -30,6 +32,9 @@ public class AuthController : ControllerBase
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest("Username, Email und Passwort sind erforderlich."); 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(); using var context = _contextFactory.CreateDbContext();
var normalizedEmail = request.Email.Trim().ToLowerInvariant(); var normalizedEmail = request.Email.Trim().ToLowerInvariant();
@@ -86,7 +91,7 @@ public class AuthController : ControllerBase
return Ok(new return Ok(new
{ {
UserId = int.Parse(userIdClaim), UserId = int.TryParse(userIdClaim, out var parsedUserId) ? parsedUserId : 0,
Username = username, Username = username,
Email = email Email = email
}); });
@@ -94,7 +99,13 @@ public class AuthController : ControllerBase
private AuthResponseDto CreateAuthResponse(User user) 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 jwtIssuer = _configuration["Jwt:Issuer"] ?? "SlipItInServer";
var jwtAudience = _configuration["Jwt:Audience"] ?? "SlipItInClient"; var jwtAudience = _configuration["Jwt:Audience"] ?? "SlipItInClient";
var expirationMinutes = int.TryParse(_configuration["Jwt:ExpirationMinutes"], out var parsedMinutes) ? parsedMinutes : 1440; var expirationMinutes = int.TryParse(_configuration["Jwt:ExpirationMinutes"], out var parsedMinutes) ? parsedMinutes : 1440;

View File

@@ -142,6 +142,7 @@ public class GameHub : Hub
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
// Geprüfte Fachfehler mit festen, harmonsierten Texten dürfen an den Client.
await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
} }
catch (Exception ex) catch (Exception ex)
@@ -242,6 +243,11 @@ public class GameHub : Hub
{ {
await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); 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) catch (Exception ex)
{ {
_logger.LogError(ex, "Error submitting slip"); _logger.LogError(ex, "Error submitting slip");
@@ -259,10 +265,16 @@ public class GameHub : Hub
var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetCardId); var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetCardId);
var game = await _gameService.GetGameAsync(gameId); 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 var challengeDto = new SlipChallengeDto
{ {
ChallengeId = challenge.Id, ChallengeId = challenge.Id,
ChallengingPlayerId = challengingPlayerId, ChallengingPlayerId = challengingPlayerId,
ChallengingPlayerName = challengingPlayer?.User.Username ?? string.Empty,
TargetPlayerId = challenge.TargetPlayerId, TargetPlayerId = challenge.TargetPlayerId,
TargetCardId = targetCardId, TargetCardId = targetCardId,
Status = challenge.Status.ToString(), Status = challenge.Status.ToString(),
@@ -272,7 +284,6 @@ public class GameHub : Hub
await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto); await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto);
// Sende direkte Notification an den beschuldigten Spieler // Sende direkte Notification an den beschuldigten Spieler
using var context = _contextFactory.CreateDbContext();
var targetPlayer = await context.Players.FindAsync(challenge.TargetPlayerId); var targetPlayer = await context.Players.FindAsync(challenge.TargetPlayerId);
if (targetPlayer?.ConnectionId != null) if (targetPlayer?.ConnectionId != null)
{ {
@@ -283,6 +294,11 @@ public class GameHub : Hub
{ {
await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); 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) catch (Exception ex)
{ {
_logger.LogError(ex, "Error challenging slip"); _logger.LogError(ex, "Error challenging slip");
@@ -372,9 +388,26 @@ public class GameHub : Hub
{ {
player.ConnectionId = Context.ConnectionId; player.ConnectionId = Context.ConnectionId;
await context.SaveChangesAsync(); 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(); await base.OnConnectedAsync();
} }
@@ -382,6 +415,23 @@ public class GameHub : Hub
public override async Task OnDisconnectedAsync(Exception? exception) public override async Task OnDisconnectedAsync(Exception? exception)
{ {
_logger.LogInformation("Client {ConnectionId} disconnected", Context.ConnectionId); _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); await base.OnDisconnectedAsync(exception);
} }
} }

View File

@@ -14,13 +14,27 @@ builder.AddServiceDefaults();
builder.AddNpgsqlDataSource(connectionName: "postgresdb"); builder.AddNpgsqlDataSource(connectionName: "postgresdb");
builder.Services.AddDbContextFactory<SlipItInDbContext>(options => options.UseNpgsql()); builder.Services.AddDbContextFactory<SlipItInDbContext>((sp, options) =>
options.UseNpgsql(sp.GetRequiredService<Npgsql.NpgsqlDataSource>()));
// Services registrieren // Services registrieren
builder.Services.AddTransient<IGameService, GameService>(); builder.Services.AddTransient<IGameService, GameService>();
// JWT Authentication // 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 jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "SlipItInServer";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "SlipItInClient"; var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "SlipItInClient";

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using SlipItIn.Server.Data; using SlipItIn.Server.Data;
using SlipItIn.Shared.DTOs; using SlipItIn.Shared.DTOs;
using SlipItIn.Shared.Models; using SlipItIn.Shared.Models;
using System.Security.Cryptography;
namespace SlipItIn.Server.Services; namespace SlipItIn.Server.Services;
@@ -21,7 +22,7 @@ public class GameService : IGameService
using var context = _contextFactory.CreateDbContext(); using var context = _contextFactory.CreateDbContext();
var host = await context.Users.FirstOrDefaultAsync(u => u.Id == hostUserId) ?? throw new InvalidOperationException("Host not found"); 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 var game = new Game
{ {
LobbyCode = lobbyCode, LobbyCode = lobbyCode,
@@ -70,7 +71,16 @@ public class GameService : IGameService
{ {
using var context = _contextFactory.CreateDbContext(); 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.Status = GameStatus.InProgress;
game.StartedAt = DateTime.UtcNow; game.StartedAt = DateTime.UtcNow;
@@ -85,12 +95,16 @@ public class GameService : IGameService
{ {
using var context = _contextFactory.CreateDbContext(); 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; player.IsReady = true;
await context.SaveChangesAsync(); await context.SaveChangesAsync();
return await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) return game;
?? throw new InvalidOperationException("Game not found");
} }
public async Task<List<PlayerCard>> DealCardsAsync(int gameId) public async Task<List<PlayerCard>> DealCardsAsync(int gameId)
@@ -108,7 +122,7 @@ public class GameService : IGameService
foreach (var player in players) 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) foreach (var phrase in selectedPhrases)
{ {
@@ -125,6 +139,7 @@ public class GameService : IGameService
} }
currentRound.Status = RoundStatus.Active; currentRound.Status = RoundStatus.Active;
currentRound.StartedAt = DateTime.UtcNow;
await context.SaveChangesAsync(); await context.SaveChangesAsync();
return dealtCards; return dealtCards;
@@ -134,10 +149,18 @@ public class GameService : IGameService
{ {
using var context = _contextFactory.CreateDbContext(); 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 var card = await context.PlayerCards
.Include(pc => pc.Player) .Include(pc => pc.GameRound)
.FirstOrDefaultAsync(pc => pc.Id == cardId) ?? throw new InvalidOperationException("Card not found"); .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.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; card.IsUsed = true;
await context.SaveChangesAsync(); await context.SaveChangesAsync();
@@ -149,13 +172,24 @@ public class GameService : IGameService
{ {
using var context = _contextFactory.CreateDbContext(); 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 var currentRound = await context.GameRounds
.Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Active) .Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Active)
.FirstOrDefaultAsync() ?? throw new InvalidOperationException("No active round found"); .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 var challenge = new SlipChallenge
{ {
@@ -179,19 +213,30 @@ public class GameService : IGameService
var challenge = await context.SlipChallenges var challenge = await context.SlipChallenges
.Include(sc => sc.GameRound) .Include(sc => sc.GameRound)
.Include(sc => sc.TargetCard) .Include(sc => sc.TargetCard)
.Include(sc => sc.ChallengingPlayer)
.Include(sc => sc.TargetPlayer)
.FirstOrDefaultAsync(sc => sc.Id == challengeId) ?? throw new InvalidOperationException("Challenge not found"); .FirstOrDefaultAsync(sc => sc.Id == challengeId) ?? throw new InvalidOperationException("Challenge not found");
// approved = Beschuldigung stimmt (Phrase war falsch) if (challenge.Status != ChallengeStatus.Pending)
// rejected = Beschuldigung stimmt nicht (Phrase war richtig) 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 // Berechtigte Beschuldigung: Beschuldigter muss die Phrase an den Beschuldiger abgeben
var targetCard = challenge.TargetCard; challenge.TargetCard.PlayerId = challenge.ChallengingPlayerId;
targetCard.PlayerId = challenge.ChallengingPlayerId; challenge.ChallengingPlayer.Score += 1;
context.PlayerCards.Update(targetCard); 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.Status = approved ? ChallengeStatus.Approved : ChallengeStatus.Rejected;
challenge.ResolvedAt = DateTime.UtcNow; challenge.ResolvedAt = DateTime.UtcNow;
@@ -218,7 +263,7 @@ public class GameService : IGameService
Username = p.User.Username, Username = p.User.Username,
Score = p.Score, Score = p.Score,
IsReady = p.IsReady, 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(); }).ToList();
var currentRound = game.Rounds var currentRound = game.Rounds
@@ -249,9 +294,17 @@ public class GameService : IGameService
{ {
using var context = _contextFactory.CreateDbContext(); 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 var cards = await context.PlayerCards
.Include(pc => pc.Phrase) .Include(pc => pc.Phrase)
.Where(pc => pc.PlayerId == playerId) .Where(pc => pc.PlayerId == playerId && activeRoundIds.Contains(pc.GameRoundId))
.Select(pc => new PlayerCardDto .Select(pc => new PlayerCardDto
{ {
CardId = pc.Id, CardId = pc.Id,
@@ -275,11 +328,25 @@ public class GameService : IGameService
?? throw new InvalidOperationException("Game not found"); ?? throw new InvalidOperationException("Game not found");
} }
private string GenerateLobbyCode() private static string GenerateLobbyCode()
{ {
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 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<string> 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");
} }
} }

View File

@@ -4,5 +4,8 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"Jwt": {
"Key": "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef"
} }
} }

View File

@@ -7,11 +7,7 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": {
"postgresdb": "Server=(localdb)\\postgres;Database=postgresdb"
},
"Jwt": { "Jwt": {
"Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
"Issuer": "SlipItInServer", "Issuer": "SlipItInServer",
"Audience": "SlipItInClient", "Audience": "SlipItInClient",
"ExpirationMinutes": 1440 "ExpirationMinutes": 1440

View File

@@ -1,9 +1,9 @@
<Solution> <Solution>
<Project Path="SlipItIN.AppHost/SlipItIn.AppHost.csproj" /> <Project Path="SlipItIn.AppHost/SlipItIn.AppHost.csproj" />
<Project Path="SlipItIn.Server/SlipItIn.Server.csproj" /> <Project Path="SlipItIn.Server/SlipItIn.Server.csproj" />
<Project Path="SlipItIN.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" /> <Project Path="SlipItIn.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
<Project Path="SlipItIn.Shared/SlipItIn.Shared.csproj" /> <Project Path="SlipItIn.Shared/SlipItIn.Shared.csproj" />
<Project Path="SlipItIN/SlipItIn.csproj"> <Project Path="SlipItIn/SlipItIn.csproj">
<Deploy Solution="Debug|*" /> <Deploy Solution="Debug|*" />
</Project> </Project>
</Solution> </Solution>

View File

@@ -5,6 +5,8 @@ namespace SlipItIn;
public partial class App : Application public partial class App : Application
{ {
private readonly SemaphoreSlim _startupLock = new(1, 1);
public App() public App()
{ {
InitializeComponent(); InitializeComponent();
@@ -21,15 +23,25 @@ public partial class App : Application
window.Resumed += async (_, _) => window.Resumed += async (_, _) =>
{ {
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>(); try
await gameStateService.ResyncAsync(); {
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
await gameStateService.ResyncAsync();
}
catch
{
// Resume darf nicht abstürzen; Sync-Status wurde bereits gemeldet.
}
}; };
return window; return window;
} }
private static async Task InitializeSafeAsync() private async Task InitializeSafeAsync()
{ {
if (!await _startupLock.WaitAsync(0))
return;
try try
{ {
var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>(); var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>();
@@ -43,8 +55,14 @@ public partial class App : Application
if (string.IsNullOrWhiteSpace(authSession.AccessToken)) if (string.IsNullOrWhiteSpace(authSession.AccessToken))
return; return;
var isSessionValid = await apiService.ValidateSessionAsync(); var sessionResult = await apiService.ValidateSessionAsync();
if (!isSessionValid) if (sessionResult == SessionValidationResult.NetworkError)
{
// Netzwerkfehler darf die lokale Session nicht zerstören.
return;
}
if (sessionResult == SessionValidationResult.Invalid)
{ {
await authSession.ClearSessionAsync(); await authSession.ClearSessionAsync();
await gameStateService.ClearLocalGameDataAsync(); await gameStateService.ClearLocalGameDataAsync();
@@ -60,5 +78,9 @@ public partial class App : Application
{ {
// Start darf nicht abstürzen, wenn Session/Netzwerk fehlschlägt. // Start darf nicht abstürzen, wenn Session/Netzwerk fehlschlägt.
} }
finally
{
_startupLock.Release();
}
} }
} }

View File

@@ -5,4 +5,5 @@ public class QueuedGameAction
public string Method { get; set; } = string.Empty; public string Method { get; set; } = string.Empty;
public string ArgumentsJson { get; set; } = "[]"; public string ArgumentsJson { get; set; } = "[]";
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public int RetryCount { get; set; }
} }

View File

@@ -28,20 +28,26 @@ public class ApiService : IApiService
public Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default) public Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default)
=> PostAuthAsync("api/auth/login", request, cancellationToken); => PostAuthAsync("api/auth/login", request, cancellationToken);
public async Task<bool> ValidateSessionAsync(CancellationToken cancellationToken = default) public async Task<SessionValidationResult> ValidateSessionAsync(CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(_authSession.AccessToken)) if (string.IsNullOrWhiteSpace(_authSession.AccessToken))
return false; return SessionValidationResult.Invalid;
try try
{ {
AttachAuthHeader(); AttachAuthHeader();
var response = await _httpClient.GetAsync("api/auth/me", cancellationToken); 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;
} }
} }

View File

@@ -28,21 +28,21 @@ public class GameStateService : IGameStateService
_signalR.LobbyCreated += (_, value) => _messenger.Send(new LobbyCreatedMessage(value)); _signalR.LobbyCreated += (_, value) => _messenger.Send(new LobbyCreatedMessage(value));
_signalR.GameStateUpdated += async (_, state) => _signalR.GameStateUpdated += (_, state) => RunSafeBridge(async () =>
{ {
CurrentGameState = state; CurrentGameState = state;
await _localStorage.SaveGameStateAsync(state); await _localStorage.SaveGameStateAsync(state);
_messenger.Send(new GameStateChangedMessage(state)); _messenger.Send(new GameStateChangedMessage(state));
}; });
_signalR.GameStarted += (_, gameId) => _messenger.Send(new GameStartedMessage(gameId)); _signalR.GameStarted += (_, gameId) => _messenger.Send(new GameStartedMessage(gameId));
_signalR.PlayerHandUpdated += async (_, hand) => _signalR.PlayerHandUpdated += (_, hand) => RunSafeBridge(async () =>
{ {
CurrentPlayerHand = hand; CurrentPlayerHand = hand;
await _localStorage.SavePlayerHandAsync(hand); await _localStorage.SavePlayerHandAsync(hand);
_messenger.Send(new PlayerHandChangedMessage(hand)); _messenger.Send(new PlayerHandChangedMessage(hand));
}; });
_signalR.ChallengeReceived += (_, challenge) => _signalR.ChallengeReceived += (_, challenge) =>
{ {
@@ -51,21 +51,26 @@ public class GameStateService : IGameStateService
}; };
_signalR.ErrorReceived += (_, error) => _messenger.Send(new ErrorOccurredMessage(error)); _signalR.ErrorReceived += (_, error) => _messenger.Send(new ErrorOccurredMessage(error));
_signalR.ConnectionStateChanged += async (_, connected) => _signalR.ConnectionStateChanged += (_, connected) => RunSafeBridge(async () =>
{ {
_messenger.Send(new ConnectionStateChangedMessage(connected)); _messenger.Send(new ConnectionStateChangedMessage(connected));
if (!connected) if (!connected)
return; return;
try await ResyncAsync();
{ });
await ResyncAsync(); }
}
catch (Exception ex) private async void RunSafeBridge(Func<Task> action)
{ {
_messenger.Send(new ErrorOccurredMessage($"Resync fehlgeschlagen: {ex.Message}")); try
} {
}; await action();
}
catch (Exception ex)
{
_messenger.Send(new ErrorOccurredMessage($"Hintergrundfehler: {ex.Message}"));
}
} }
public async Task InitializeAsync() public async Task InitializeAsync()
@@ -153,12 +158,16 @@ public class GameStateService : IGameStateService
PublishSyncState(false, "Bereit", 0); PublishSyncState(false, "Bereit", 0);
} }
private const int MaxQueueRetries = 3;
private async Task FlushQueuedActionsCoreAsync() private async Task FlushQueuedActionsCoreAsync()
{ {
if (!_signalR.IsConnected || _queuedActions.Count == 0) if (!_signalR.IsConnected || _queuedActions.Count == 0)
return; return;
var snapshot = _queuedActions.ToList(); var snapshot = _queuedActions.ToList();
var poisoned = new List<QueuedGameAction>();
foreach (var action in snapshot) foreach (var action in snapshot)
{ {
try try
@@ -169,11 +178,23 @@ public class GameStateService : IGameStateService
} }
catch (Exception ex) catch (Exception ex)
{ {
_messenger.Send(new ErrorOccurredMessage($"Queue-Aktion fehlgeschlagen ({action.Method}): {ex.Message}")); action.RetryCount++;
break; 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); await _localStorage.SaveQueuedActionsAsync(_queuedActions);
} }

View File

@@ -2,10 +2,17 @@ using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services.Interfaces; namespace SlipItIn.Services.Interfaces;
public enum SessionValidationResult
{
Valid,
Invalid,
NetworkError
}
public interface IApiService public interface IApiService
{ {
Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default); Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default); Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
Task<bool> ValidateSessionAsync(CancellationToken cancellationToken = default); Task<SessionValidationResult> ValidateSessionAsync(CancellationToken cancellationToken = default);
Task LogoutAsync(); Task LogoutAsync();
} }

View File

@@ -1,21 +1,25 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using SlipItIn.Models; using SlipItIn.Models;
using SlipItIn.Services.Interfaces; using SlipItIn.Services.Interfaces;
using SlipItIn.Shared.DTOs; using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services; namespace SlipItIn.Services;
public class SignalRService : ISignalRService public class SignalRService : ISignalRService, IAsyncDisposable
{ {
private readonly IAppConfigurationService _configuration; private readonly IAppConfigurationService _configuration;
private readonly IAuthSessionService _authSession; private readonly IAuthSessionService _authSession;
private readonly IServiceProvider _serviceProvider;
private readonly SemaphoreSlim _connectLock = new(1, 1);
private HubConnection? _hubConnection; private HubConnection? _hubConnection;
public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession) public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession, IServiceProvider serviceProvider)
{ {
_configuration = configuration; _configuration = configuration;
_authSession = authSession; _authSession = authSession;
_serviceProvider = serviceProvider;
} }
public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected; public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected;
@@ -33,36 +37,76 @@ public class SignalRService : ISignalRService
if (IsConnected) if (IsConnected)
return true; return true;
EnsureConnection(token); await _connectLock.WaitAsync(cancellationToken);
if (_hubConnection is null)
return false;
ConnectionStateChanged?.Invoke(this, false);
try try
{ {
await _hubConnection.StartAsync(cancellationToken); if (IsConnected)
ConnectionStateChanged?.Invoke(this, true); return true;
return true;
} // Bestehende (möglicherweise tote) Verbindung vollständig entsorgen,
catch (Exception ex) // 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); 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() public async Task DisconnectAsync()
{ {
if (_hubConnection is null) await _connectLock.WaitAsync();
return; try
{
if (_hubConnection is null)
return;
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting) if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
await _hubConnection.StopAsync(); await _hubConnection.StopAsync();
ConnectionStateChanged?.Invoke(this, false); await _hubConnection.DisposeAsync();
_hubConnection = null;
ConnectionStateChanged?.Invoke(this, false);
}
finally
{
_connectLock.Release();
}
} }
public Task CreateLobbyAsync() => InvokeAsync("CreateLobby"); public Task CreateLobbyAsync() => InvokeAsync("CreateLobby");
@@ -87,7 +131,7 @@ public class SignalRService : ISignalRService
public async Task SendQueuedActionAsync(QueuedGameAction action) public async Task SendQueuedActionAsync(QueuedGameAction action)
{ {
if (_hubConnection is null) if (_hubConnection is null)
return; throw new InvalidOperationException("Keine aktive SignalR-Verbindung.");
object?[] args; object?[] args;
try try
@@ -95,11 +139,11 @@ public class SignalRService : ISignalRService
var doc = JsonDocument.Parse(action.ArgumentsJson); var doc = JsonDocument.Parse(action.ArgumentsJson);
args = doc.RootElement.ValueKind == JsonValueKind.Array args = doc.RootElement.ValueKind == JsonValueKind.Array
? doc.RootElement.EnumerateArray().Select(ToObject).ToArray() ? 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); await InvokeAsync(action.Method, args);
@@ -113,7 +157,8 @@ public class SignalRService : ISignalRService
_hubConnection = new HubConnectionBuilder() _hubConnection = new HubConnectionBuilder()
.WithUrl(_configuration.HubUrl, options => .WithUrl(_configuration.HubUrl, options =>
{ {
options.AccessTokenProvider = () => Task.FromResult<string?>(_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)]) .WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)])
.Build(); .Build();
@@ -155,6 +200,13 @@ public class SignalRService : ISignalRService
_hubConnection.On<PlayerHandDto>("PlayerHandUpdated", dto => PlayerHandUpdated?.Invoke(this, dto)); _hubConnection.On<PlayerHandDto>("PlayerHandUpdated", dto => PlayerHandUpdated?.Invoke(this, dto));
_hubConnection.On<SlipChallengeDto>("ChallengeReceived", dto => ChallengeReceived?.Invoke(this, dto)); _hubConnection.On<SlipChallengeDto>("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<JsonElement>("SlipSubmitted", _ => RequestCurrentGameStateSafe());
_hubConnection.On<JsonElement>("SlipChallenged", _ => RequestCurrentGameStateSafe());
_hubConnection.On<JsonElement>("ChallengeResolved", _ => RequestCurrentGameStateSafe());
_hubConnection.On<JsonElement>("CardsDealt", _ => RequestCurrentGameStateSafe());
_hubConnection.On<JsonElement>("Error", payload => _hubConnection.On<JsonElement>("Error", payload =>
{ {
var message = payload.TryGetProperty("message", out var msgElement) 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<IGameStateService>();
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) private async Task InvokeAsync(string method, params object?[] args)
{ {
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected) if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
@@ -186,4 +258,10 @@ public class SignalRService : ISignalRService
_ => element.GetRawText() _ => element.GetRawText()
}; };
} }
public async ValueTask DisposeAsync()
{
if (_hubConnection is not null)
await _hubConnection.DisposeAsync();
}
} }

View File

@@ -48,35 +48,11 @@
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion> <TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-android|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)'=='Debug'">
<ApplicationTitle>SlipItIn</ApplicationTitle> <ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-ios|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)'=='Release'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-maccatalyst|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-windows10.0.19041.0|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-ios|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-maccatalyst|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-windows10.0.19041.0|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle> <ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup> </PropertyGroup>
@@ -89,7 +65,6 @@
<!-- Images --> <!-- Images -->
<MauiImage Include="Resources\Images\*" /> <MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts --> <!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" /> <MauiFont Include="Resources\Fonts\*" />
@@ -100,7 +75,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.90" /> <PackageReference Include="Microsoft.Maui.Controls" Version="10.0.90" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="11.0.0-preview.6.26359.118" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.10" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.10" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.10" />
</ItemGroup> </ItemGroup>
@@ -110,9 +85,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<MauiXaml Update="MainPage.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
<MauiXaml Update="Views\ChallengeNotificationOverlay.xaml"> <MauiXaml Update="Views\ChallengeNotificationOverlay.xaml">
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
</MauiXaml> </MauiXaml>

View File

@@ -19,6 +19,8 @@ public partial class GameBoardViewModel : BaseViewModel,
private readonly ISignalRService _signalR; private readonly ISignalRService _signalR;
private readonly IGameStateService _gameStateService; private readonly IGameStateService _gameStateService;
private readonly IAuthSessionService _authSession; private readonly IAuthSessionService _authSession;
private readonly IDispatcherTimer? _roundTimer;
private DateTime _lastStateUtc = DateTime.UtcNow;
[ObservableProperty] [ObservableProperty]
private int gameId; private int gameId;
@@ -58,6 +60,26 @@ public partial class GameBoardViewModel : BaseViewModel,
IsConnected = _signalR.IsConnected; IsConnected = _signalR.IsConnected;
IsActive = true; 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] [RelayCommand]
@@ -123,9 +145,22 @@ public partial class GameBoardViewModel : BaseViewModel,
if (PendingChallenge is null) if (PendingChallenge is null)
return; return;
await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, true); var challengeId = PendingChallenge.ChallengeId;
PendingChallenge = null; PendingChallenge = null;
IsChallengeVisible = false; 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] [RelayCommand]
@@ -134,9 +169,22 @@ public partial class GameBoardViewModel : BaseViewModel,
if (PendingChallenge is null) if (PendingChallenge is null)
return; return;
await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, false); var challengeId = PendingChallenge.ChallengeId;
PendingChallenge = null; PendingChallenge = null;
IsChallengeVisible = false; 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) public void Receive(GameStateChangedMessage message)
@@ -146,6 +194,7 @@ public partial class GameBoardViewModel : BaseViewModel,
GameId = message.Value.GameId; GameId = message.Value.GameId;
CurrentRound = message.Value.CurrentRound; CurrentRound = message.Value.CurrentRound;
RoundTimeRemaining = message.Value.RoundTimeRemaining; RoundTimeRemaining = message.Value.RoundTimeRemaining;
_lastStateUtc = DateTime.UtcNow;
var me = _authSession.CurrentUser?.Username; var me = _authSession.CurrentUser?.Username;
var others = message.Value.Players var others = message.Value.Players
@@ -170,27 +219,36 @@ public partial class GameBoardViewModel : BaseViewModel,
public void Receive(ChallengeReceivedMessage message) public void Receive(ChallengeReceivedMessage message)
{ {
PendingChallenge = message.Value; MainThread.BeginInvokeOnMainThread(() =>
IsChallengeVisible = true; {
PendingChallenge = message.Value;
IsChallengeVisible = true;
});
} }
public void Receive(ErrorOccurredMessage message) public void Receive(ErrorOccurredMessage message)
{ {
StatusMessage = message.Value; MainThread.BeginInvokeOnMainThread(() => StatusMessage = message.Value);
} }
public void Receive(SyncStateChangedMessage message) public void Receive(SyncStateChangedMessage message)
{ {
IsSyncing = message.Value.IsSyncing; MainThread.BeginInvokeOnMainThread(() =>
SyncStatusText = message.Value.StatusText; {
PendingSyncActions = message.Value.PendingActions; IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
});
} }
public void Receive(ConnectionStateChangedMessage message) 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() private int GetCurrentPlayerId()
{ {
var username = _authSession.CurrentUser?.Username; var username = _authSession.CurrentUser?.Username;

View File

@@ -55,6 +55,16 @@ public partial class LobbyViewModel : BaseViewModel,
IsActive = true; IsActive = true;
} }
/// <summary>
/// Reaktiviert das ViewModel nach einem Logout (Singleton-Lebensdauer).
/// Wird von LobbyPage.OnNavigatedTo aufgerufen.
/// </summary>
public void Activate()
{
IsConnected = _signalR.IsConnected;
IsActive = true;
}
[RelayCommand] [RelayCommand]
private async Task CreateLobbyAsync() private async Task CreateLobbyAsync()
{ {
@@ -105,6 +115,7 @@ public partial class LobbyViewModel : BaseViewModel,
{ {
await RunSafeAsync(async () => await RunSafeAsync(async () =>
{ {
IsActive = false;
await _signalR.DisconnectAsync(); await _signalR.DisconnectAsync();
await _apiService.LogoutAsync(); await _apiService.LogoutAsync();
await _gameStateService.ClearLocalGameDataAsync(); 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) public void Receive(ErrorOccurredMessage message)
{ {
StatusMessage = message.Value; MainThread.BeginInvokeOnMainThread(() => StatusMessage = message.Value);
} }
public void Receive(ConnectionStateChangedMessage message) public void Receive(ConnectionStateChangedMessage message)
{ {
IsConnected = message.Value; MainThread.BeginInvokeOnMainThread(() => IsConnected = message.Value);
} }
public void Receive(SyncStateChangedMessage message) public void Receive(SyncStateChangedMessage message)
{ {
IsSyncing = message.Value.IsSyncing; MainThread.BeginInvokeOnMainThread(() =>
SyncStatusText = message.Value.StatusText; {
PendingSyncActions = message.Value.PendingActions; IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
});
} }
private int GetCurrentPlayerId() private int GetCurrentPlayerId()

View File

@@ -58,8 +58,11 @@ public partial class LoginViewModel : BaseViewModel
if (!_authSession.IsAuthenticated) if (!_authSession.IsAuthenticated)
return; return;
var isSessionValid = await _apiService.ValidateSessionAsync(); var sessionResult = await _apiService.ValidateSessionAsync();
if (!isSessionValid) if (sessionResult == SessionValidationResult.NetworkError)
return;
if (sessionResult == SessionValidationResult.Invalid)
{ {
await _authSession.ClearSessionAsync(); await _authSession.ClearSessionAsync();
await _gameStateService.ClearLocalGameDataAsync(); await _gameStateService.ClearLocalGameDataAsync();

View File

@@ -44,7 +44,9 @@ public partial class RegisterViewModel : BaseViewModel
}); });
await _authSession.SetSessionAsync(response); 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)); await Shell.Current.GoToAsync(nameof(LobbyPage));
}, "Registrierung läuft..."); }, "Registrierung läuft...");
} }

View File

@@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="SlipItIn.Views.ChallengeNotificationOverlay"> xmlns:vm="clr-namespace:SlipItIn.ViewModels"
x:Class="SlipItIn.Views.ChallengeNotificationOverlay"
x:DataType="vm:GameBoardViewModel">
<Grid BackgroundColor="#88000000" IsVisible="{Binding IsChallengeVisible}" Padding="24"> <Grid BackgroundColor="#88000000" IsVisible="{Binding IsChallengeVisible}" Padding="24">
<Frame VerticalOptions="Center" HorizontalOptions="Center"> <Border VerticalOptions="Center" HorizontalOptions="Center" Stroke="LightGray" StrokeShape="RoundRectangle 12" BackgroundColor="White" Padding="16">
<VerticalStackLayout Spacing="10"> <VerticalStackLayout Spacing="10">
<Label Text="Du wurdest beschuldigt" FontAttributes="Bold" /> <Label Text="Du wurdest beschuldigt" FontAttributes="Bold" />
<Label Text="{Binding PendingChallenge.ChallengeId, StringFormat='ChallengeId: {0}'}" /> <Label Text="{Binding PendingChallenge.ChallengeId, StringFormat='ChallengeId: {0}'}" />
@@ -12,6 +14,6 @@
<Button Text="Ablehnen" Command="{Binding RejectChallengeCommand}" /> <Button Text="Ablehnen" Command="{Binding RejectChallengeCommand}" />
</HorizontalStackLayout> </HorizontalStackLayout>
</VerticalStackLayout> </VerticalStackLayout>
</Frame> </Border>
</Grid> </Grid>
</ContentView> </ContentView>

View File

@@ -5,9 +5,23 @@ namespace SlipItIn.Views;
public partial class GameBoardPage : ContentPage public partial class GameBoardPage : ContentPage
{ {
private readonly GameBoardViewModel _viewModel;
public GameBoardPage() public GameBoardPage()
{ {
InitializeComponent(); InitializeComponent();
BindingContext = ServiceHelper.GetRequiredService<GameBoardViewModel>(); BindingContext = _viewModel = ServiceHelper.GetRequiredService<GameBoardViewModel>();
}
protected override void OnNavigatedTo(NavigatedToEventArgs args)
{
base.OnNavigatedTo(args);
_viewModel.StartRoundTimer();
}
protected override void OnNavigatedFrom(NavigatedFromEventArgs args)
{
base.OnNavigatedFrom(args);
_viewModel.StopRoundTimer();
} }
} }

View File

@@ -5,9 +5,17 @@ namespace SlipItIn.Views;
public partial class LobbyPage : ContentPage public partial class LobbyPage : ContentPage
{ {
private readonly LobbyViewModel _viewModel;
public LobbyPage() public LobbyPage()
{ {
InitializeComponent(); InitializeComponent();
BindingContext = ServiceHelper.GetRequiredService<LobbyViewModel>(); BindingContext = _viewModel = ServiceHelper.GetRequiredService<LobbyViewModel>();
}
protected override void OnNavigatedTo(NavigatedToEventArgs args)
{
base.OnNavigatedTo(args);
_viewModel.Activate();
} }
} }

View File

@@ -1,5 +1,5 @@
{ {
"appHost": { "appHost": {
"path": "SlipItIN.AppHost/SlipItIN.AppHost.csproj" "path": "SlipItIn.AppHost/SlipItIn.AppHost.csproj"
} }
} }