Refinement of Plans
This commit is contained in:
136
Agents/Architecture.md
Normal file
136
Agents/Architecture.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Slip It In – Architektur-Updates (Phase 2b & 3b)
|
||||||
|
|
||||||
|
Dieses Dokument fasst die Sicherheits- und Stabilitäts-Erweiterungen zusammen, die dem ProjectPlan.md hinzugefügt wurden.
|
||||||
|
|
||||||
|
## 🔒 Backend-Sicherheit (Phase 2b)
|
||||||
|
|
||||||
|
### 1. JWT-Claims Validation im GameHub
|
||||||
|
- **Problem**: Clients könnten falsche PlayerId-Claims senden (Spoofing)
|
||||||
|
- **Lösung**: `GetAuthenticatedUserId()` extrahiert User-ID aus JWT-Claims
|
||||||
|
- **Implementierung**: Jede Hub-Methode validiert `Context.User?.FindFirst(ClaimTypes.NameIdentifier)`
|
||||||
|
|
||||||
|
### 2. IDbContextFactory statt Scoped DbContext
|
||||||
|
- **Problem**: Bei parallelen SignalR-Aufrufen kann es zu Race Conditions kommen
|
||||||
|
- **Lösung**: `IDbContextFactory<SlipItInDbContext>` erzeugt isolierte Sessions pro Aufruf
|
||||||
|
- **Vorteil**: Phrase-Transfer ist thread-safe, keine Conflicts bei 2 gleichzeitigen Challenges
|
||||||
|
|
||||||
|
### 3. Serverseitige Validierung
|
||||||
|
- **Regel**: Clients senden nur "Ich möchte X machen", Server prüft ALLES
|
||||||
|
- **Beispiel**: `SubmitSlipAsync()` prüft auf dem Server:
|
||||||
|
- ✅ Gehört die Phrase dem Spieler?
|
||||||
|
- ✅ Ist die Runde aktiv?
|
||||||
|
- ✅ Ist der Spieler noch im Spiel?
|
||||||
|
|
||||||
|
### 4. Daten-Isolation (GameStateDto vs. PlayerHandDto)
|
||||||
|
- **GameStateDto**: Öffentliche Daten (Username, Score, CardCount) → für ALLE sichtbar
|
||||||
|
- **PlayerHandDto**: Private Daten (Text der eigenen Karten) → nur für den Spieler selbst
|
||||||
|
- **Ergebnis**: Gegner sehen nicht, welche Phrasen ich habe!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 Client-Stabilität (Phase 3b)
|
||||||
|
|
||||||
|
### 1. WeakReferenceMessenger für Event-Entkopplung
|
||||||
|
- **Problem**: Services rufen ViewModels direkt auf → Memory Leaks wenn VM disposed
|
||||||
|
- **Lösung**: Services senden Messages via `WeakReferenceMessenger.Default.Send()`
|
||||||
|
- **Vorteil**: ViewModels können sich abmelden ohne zirkuläre Abhängigkeiten
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```csharp
|
||||||
|
// Service sendet Event
|
||||||
|
WeakReferenceMessenger.Default.Send(new GameStateChangedMessage(newState));
|
||||||
|
|
||||||
|
// ViewModel empfängt (mit auto-cleanup beim Dispose)
|
||||||
|
WeakReferenceMessenger.Default.Register<GameStateChangedMessage>(this, (r, m) =>
|
||||||
|
{
|
||||||
|
CurrentGameState = m.Value;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Automatischer Reconnect
|
||||||
|
- **Strategie**: Exponential Backoff (0s → 2s → 10s → 30s)
|
||||||
|
- **Pufferung**: Offline-Aktionen werden in lokale Queue geschrieben
|
||||||
|
- **Resync**: Nach Reconnect wird `ResyncAsync()` aufgerufen
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. SignalR-Verbindung getrennt
|
||||||
|
2. Client speichert "Slip zu Phrase X" in lokale Queue
|
||||||
|
3. Nach 2s Automatischer Reconnect-Versuch
|
||||||
|
4. Nach erfolgreichem Reconnect: Queued Actions werden abgesendet
|
||||||
|
|
||||||
|
### 3. State Restoration
|
||||||
|
- **App-Pause**: `GameStateService` speichert aktuellen State in `SecureStorage`
|
||||||
|
- **App-Resume**: `ResyncAsync()` wird aufgerufen
|
||||||
|
- ✅ Holt aktuellen GameState vom Server
|
||||||
|
- ✅ Sendet gepufferte Offline-Aktionen erneut
|
||||||
|
- ✅ UI zeigt "Syncing..." bis fertig
|
||||||
|
|
||||||
|
### 4. Dual-Layer Storage
|
||||||
|
- **In-Memory Layer**: `GameStateService` halten aktuellen State
|
||||||
|
- **Persistent Layer**: `SecureStorage`/`Preferences` speichern Backup
|
||||||
|
- **Fehler-Handling**: Nach App-Crash kann State aus Persistent Layer wiederhergestellt werden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Schnellreferenz: Neue Components
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```
|
||||||
|
GameHub
|
||||||
|
├─ GetAuthenticatedUserId() → JWT-Validierung
|
||||||
|
├─ ValidatePlayerAccessAsync() → Authorization Check
|
||||||
|
└─ Alle Methoden nutzen IDbContextFactory
|
||||||
|
|
||||||
|
Program.cs
|
||||||
|
├─ AddDbContextFactory<SlipItInDbContext>()
|
||||||
|
├─ AddAuthentication(JwtBearerDefaults)
|
||||||
|
└─ AddSignalR() mit JWT-Filter
|
||||||
|
|
||||||
|
DTOs
|
||||||
|
├─ GameStateDto (öffentlich)
|
||||||
|
├─ PlayerHandDto (privat)
|
||||||
|
└─ Keine CardText in GameStateDto!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client
|
||||||
|
```
|
||||||
|
GameStateService
|
||||||
|
├─ CurrentGameState (in-memory)
|
||||||
|
├─ CurrentPlayerHand (in-memory)
|
||||||
|
├─ UpdateGameStateAsync() → speichert + sendet Message
|
||||||
|
└─ ResyncAsync() → bei App-Resume
|
||||||
|
|
||||||
|
SignalRService
|
||||||
|
├─ ConnectAsync(token) mit Auto-Reconnect
|
||||||
|
├─ RequestGameStateAsync()
|
||||||
|
└─ On<> Handler senden Messages
|
||||||
|
|
||||||
|
Messages (WeakReferenceMessenger)
|
||||||
|
├─ GameStateChangedMessage
|
||||||
|
├─ PlayerHandChangedMessage
|
||||||
|
└─ ChallengeReceivedMessage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Wichtigste Tests
|
||||||
|
|
||||||
|
| Test | Ziel |
|
||||||
|
|------|------|
|
||||||
|
| **JWT-Auth** | Hub lehnt Requests ohne gültiges Token ab |
|
||||||
|
| **Player-Access** | User A kann nicht auf Players von User B zugreifen |
|
||||||
|
| **Race Condition** | Phrase-Transfer ist thread-safe bei 2 gleichzeitigen Challenges |
|
||||||
|
| **Offline-Queue** | Aktionen während Netzwerk-Ausfall werden gepuffert und später gesendet |
|
||||||
|
| **Memory Leaks** | Keine Leaks wenn ViewModels disposed werden (WeakReference) |
|
||||||
|
| **State-Sync** | App-Resume synct korrekt mit Server-State |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Implementierungs-Reihenfolge
|
||||||
|
|
||||||
|
1. **Backend zuerst**: JWT-Auth + IDbContextFactory konfigurieren
|
||||||
|
2. **GameService erweitern**: Serverseitige Validierung in alle Methoden
|
||||||
|
3. **Client-Services**: GameStateService + SignalRService implementieren
|
||||||
|
4. **Messaging**: WeakReferenceMessenger in ViewModels integrieren
|
||||||
|
5. **Tests**: Integrationstest-Suite schreiben
|
||||||
|
6. **UI**: Views an neue Message-Events binden
|
||||||
@@ -10,7 +10,11 @@ Dieses Dokument beschreibt die Phasen und Aufgaben zur Implementierung des Spiel
|
|||||||
|
|
||||||
## Phase 2: Backend-Entwicklung (Web API & SignalR)
|
## Phase 2: Backend-Entwicklung (Web API & SignalR)
|
||||||
- [ ] **Datenbank & Persistenz**: Entity Framework Core Setup mit SQLite
|
- [ ] **Datenbank & Persistenz**: Entity Framework Core Setup mit SQLite
|
||||||
- [ ] **User Management**: Simple Registrierung, Login und JWT-basierte Authentifizierung
|
- [ ] **User Management**: Einfache JWT-basierte Authentifizierung
|
||||||
|
- Registrierung (Username, Email, Passwort)
|
||||||
|
- Login mit BCrypt Password Hashing
|
||||||
|
- JWT Tokens für API-Authentifizierung
|
||||||
|
- Token-Validierung auf API-Endpoints
|
||||||
- [ ] **Game Engine & Hub**:
|
- [ ] **Game Engine & Hub**:
|
||||||
- `GameHub` für Echtzeit-Kommunikation
|
- `GameHub` für Echtzeit-Kommunikation
|
||||||
- Logik für Lobby-Erstellung und Beitritt via Code
|
- Logik für Lobby-Erstellung und Beitritt via Code
|
||||||
@@ -23,19 +27,51 @@ Dieses Dokument beschreibt die Phasen und Aufgaben zur Implementierung des Spiel
|
|||||||
- Bei **berechtigter Beschuldigung**: Beschuldigter muss Phrase abgeben
|
- Bei **berechtigter Beschuldigung**: Beschuldigter muss Phrase abgeben
|
||||||
- Timer-gesteuerte Logik für Slip-Runden (30-60s pro Slip)
|
- Timer-gesteuerte Logik für Slip-Runden (30-60s pro Slip)
|
||||||
|
|
||||||
|
## Phase 2b: Backend-Erweiterungen (Sicherheit & In-Memory-State)
|
||||||
|
- [ ] **SignalR Auth Integration**: JWT-Claims Validation im `GameHub`
|
||||||
|
- Verhindert Spoofing von PlayerIds
|
||||||
|
- User-Identity aus JWT extrahiert und validiert
|
||||||
|
- Nur autorisierte Spieler können auf ihre Daten zugreifen
|
||||||
|
- [ ] **Datenschutz/Karten-Isolation**: Trennung von öffentlichen und privaten Daten
|
||||||
|
- `GameStateDto` – Öffentliche Spieldaten (Spieler, Scores, aktive Runden)
|
||||||
|
- `PlayerHandDto` – Private Handkarten (nur für den Spieler selbst sichtbar)
|
||||||
|
- Clients erhalten nur Daten, die sie sehen dürfen
|
||||||
|
- [ ] **EF Core Context Factory**: Umstellung von Scoped auf `IDbContextFactory`
|
||||||
|
- Vermeidet Parallelitätskonflikte bei SignalR-Aufrufen
|
||||||
|
- Jede SignalR-Methode nutzt eine eigene, isolierte DB-Session
|
||||||
|
- Verhindert Race Conditions beim Phrase-Transfer
|
||||||
|
- [ ] **Serverseitige Validierung**: Logik-Verlagerung auf Server
|
||||||
|
- Clients senden nur Wünsche (z.B. "Ich möchte dieser Phrase zuordnen")
|
||||||
|
- Server prüft alle Bedingungen (IsPlayersTurn, CardBelongsToPlayer, etc.)
|
||||||
|
- Server speichert nur validierte Zustandsänderungen in DB
|
||||||
|
|
||||||
## Phase 3: Client-Entwicklung (.NET MAUI)
|
## Phase 3: Client-Entwicklung (.NET MAUI)
|
||||||
- [ ] **Infrastruktur**:
|
- [ ] **Infrastruktur**:
|
||||||
- MVVM-Setup mit CommunityToolkit.Mvvm
|
- MVVM-Setup mit CommunityToolkit.Mvvm
|
||||||
- Integration von Dependency Injection für Services
|
- Integration von Dependency Injection für Services
|
||||||
|
- Messenger-Architektur mit `WeakReferenceMessenger` für Event-Entkopplung
|
||||||
- [ ] **Services**:
|
- [ ] **Services**:
|
||||||
- `ApiService` für REST-Anfragen
|
- `ApiService` für REST-Anfragen (mit JWT-Token aus `SecureStorage`)
|
||||||
- `SignalRService` für Echtzeit-Events
|
- `SignalRService` für Echtzeit-Events mit automatischem Reconnect
|
||||||
|
- `GameStateService` für In-Memory-Spielzustand-Verwaltung
|
||||||
|
- `LocalStorageService` für State-Persistence (Wiederherstellung nach Crash)
|
||||||
- [ ] **UI/Views**:
|
- [ ] **UI/Views**:
|
||||||
- `LoginPage` & `RegisterPage`
|
- `LoginPage` & `RegisterPage`
|
||||||
- `LobbyPage` (Spielerliste & Start-Button für Host)
|
- `LobbyPage` (Spielerliste & Start-Button für Host)
|
||||||
- `GameBoardPage` (Anzeige aller eigenen Phrasen, "Slip"-Button pro Phrase, Timer-Anzeige, gegnerische Spieler-Liste mit "Beschuldigen"-Button)
|
- `GameBoardPage` (Anzeige aller eigenen Phrasen, "Slip"-Button pro Phrase, Timer-Anzeige, gegnerische Spieler-Liste mit "Beschuldigen"-Button)
|
||||||
- `ChallengeNotificationOverlay` (Eingangende Beschuldigung: "Bestätigen" oder "Ablehnen"-Button)
|
- `ChallengeNotificationOverlay` (Eingangende Beschuldigung: "Bestätigen" oder "Ablehnen"-Button)
|
||||||
|
|
||||||
|
## Phase 3b: MAUI Client-Stabilität
|
||||||
|
- [ ] **Messenger-Architektur**: Entkopplung via `WeakReferenceMessenger`
|
||||||
|
- SignalR-Events erzeugen Messages (z.B. `GameStateChangedMessage`)
|
||||||
|
- ViewModels abonnieren Messages statt direkter Service-Events
|
||||||
|
- Verhindert Memory Leaks durch Circular Dependencies
|
||||||
|
- [ ] **State Restoration**: Automatisches Re-Syncing
|
||||||
|
- App-Resume triggert `GameStateService.ResyncAsync()`
|
||||||
|
- SignalR-Reconnect lädt aktuellen Spielzustand vom Server
|
||||||
|
- Offline-Puffering: Lokale Aktionen werden queued und nach Reconnect abgesendet
|
||||||
|
- UI zeigt "Syncing..." bis Verbindung wiederhergestellt
|
||||||
|
|
||||||
## Phase 4: Testen & Optimierung
|
## Phase 4: Testen & Optimierung
|
||||||
- [ ] Integrationstest der SignalR-Kommunikation zwischen mehreren MAUI-Clients
|
- [ ] Integrationstest der SignalR-Kommunikation zwischen mehreren MAUI-Clients
|
||||||
- [ ] Behandlung von Verbindungsabbrüchen (Reconnection Logic in SignalR)
|
- [ ] Behandlung von Verbindungsabbrüchen (Reconnection Logic in SignalR)
|
||||||
@@ -47,6 +83,20 @@ Dieses Dokument beschreibt die Phasen und Aufgaben zur Implementierung des Spiel
|
|||||||
|
|
||||||
**Datenbank-Entscheidung:** SQLite wird für Development und Production verwendet. Die `slipitIn.db` Datei wird im Server-Projekt abgelegt und über Connection String konfiguriert.
|
**Datenbank-Entscheidung:** SQLite wird für Development und Production verwendet. Die `slipitIn.db` Datei wird im Server-Projekt abgelegt und über Connection String konfiguriert.
|
||||||
|
|
||||||
|
### Architektur-Highlights (Sicherheit & Stabilität)
|
||||||
|
|
||||||
|
#### Backend-Sicherheit:
|
||||||
|
- **JWT-Claims Validation** im GameHub → Verhindert PlayerId-Spoofing
|
||||||
|
- **IDbContextFactory** statt Scoped DbContext → Isoliert SignalR-Aufrufe, verhindert Race Conditions
|
||||||
|
- **Serverseitige Validierung** → Clients senden nur Wünsche, Server prüft alles
|
||||||
|
- **Daten-Isolation** → `GameStateDto` (öffentlich) vs. `PlayerHandDto` (privat)
|
||||||
|
|
||||||
|
#### Client-Stabilität:
|
||||||
|
- **WeakReferenceMessenger** → Entkopplung von Services & ViewModels, verhindert Memory Leaks
|
||||||
|
- **Automatischer Reconnect** → SignalR mit exponentialem Backoff (0s, 2s, 10s, 30s)
|
||||||
|
- **State Persistence** → Offline-Aktionen in Queue, Auto-Resync nach App-Resume
|
||||||
|
- **Dual-Layer Storage** → In-Memory (`GameStateService`) + Local (`SecureStorage`/`Preferences`)
|
||||||
|
|
||||||
### Spielmechanik-Übersicht
|
### Spielmechanik-Übersicht
|
||||||
|
|
||||||
**Grundkonzept:**
|
**Grundkonzept:**
|
||||||
@@ -337,16 +387,25 @@ public class GameStateDto
|
|||||||
|
|
||||||
public class PlayerInfoDto
|
public class PlayerInfoDto
|
||||||
{
|
{
|
||||||
|
// Öffentliche Daten (für alle sichtbar)
|
||||||
public int PlayerId { get; set; }
|
public int PlayerId { get; set; }
|
||||||
public string Username { get; set; } = string.Empty;
|
public string Username { get; set; } = string.Empty;
|
||||||
public int Score { get; set; }
|
public int Score { get; set; }
|
||||||
public bool IsReady { get; set; }
|
public bool IsReady { get; set; }
|
||||||
public List<PhraseDto> ActiveCards { get; set; } = [];
|
public int CardCount { get; set; } // Anzahl der Karten (nicht die Karten selbst!)
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PhraseDto
|
public class PlayerHandDto
|
||||||
|
{
|
||||||
|
// Private Daten (nur für den Spieler selbst)
|
||||||
|
public int PlayerId { get; set; }
|
||||||
|
public List<PlayerCardDto> Cards { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlayerCardDto
|
||||||
{
|
{
|
||||||
public int CardId { get; set; }
|
public int CardId { get; set; }
|
||||||
|
public int PhraseId { get; set; }
|
||||||
public string Text { get; set; } = string.Empty;
|
public string Text { get; set; } = string.Empty;
|
||||||
public bool IsUsed { get; set; }
|
public bool IsUsed { get; set; }
|
||||||
}
|
}
|
||||||
@@ -356,8 +415,10 @@ public class SlipChallengeDto
|
|||||||
public int ChallengeId { get; set; }
|
public int ChallengeId { get; set; }
|
||||||
public int ChallengingPlayerId { get; set; }
|
public int ChallengingPlayerId { get; set; }
|
||||||
public string ChallengingPlayerName { get; set; } = string.Empty;
|
public string ChallengingPlayerName { get; set; } = string.Empty;
|
||||||
|
public int TargetPlayerId { get; set; }
|
||||||
public int TargetCardId { get; set; }
|
public int TargetCardId { get; set; }
|
||||||
public string Status { get; set; } = string.Empty;
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -366,25 +427,55 @@ public class SlipChallengeDto
|
|||||||
public class GameHub : Hub
|
public class GameHub : Hub
|
||||||
{
|
{
|
||||||
private readonly IGameService _gameService;
|
private readonly IGameService _gameService;
|
||||||
|
private readonly IDbContextFactory<SlipItInDbContext> _contextFactory;
|
||||||
private readonly ILogger<GameHub> _logger;
|
private readonly ILogger<GameHub> _logger;
|
||||||
|
|
||||||
public GameHub(IGameService gameService, ILogger<GameHub> logger)
|
public GameHub(IGameService gameService, IDbContextFactory<SlipItInDbContext> contextFactory, ILogger<GameHub> logger)
|
||||||
{
|
{
|
||||||
_gameService = gameService;
|
_gameService = gameService;
|
||||||
|
_contextFactory = contextFactory;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validiert die JWT-Claims des aktuellen Nutzers
|
||||||
|
/// Wirft Exception wenn User nicht authentifiziert oder Claim fehlt
|
||||||
|
/// </summary>
|
||||||
|
private int GetAuthenticatedUserId()
|
||||||
|
{
|
||||||
|
var userIdClaim = Context.User?.FindFirst(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId))
|
||||||
|
throw new UnauthorizedAccessException("Invalid or missing user claim");
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sichert ab, dass ein Spieler nur auf seine eigenen Daten zugreift
|
||||||
|
/// </summary>
|
||||||
|
private async Task ValidatePlayerAccessAsync(int playerId, int authUserId)
|
||||||
|
{
|
||||||
|
using var context = _contextFactory.CreateDbContext();
|
||||||
|
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
|
if (player?.UserId != authUserId)
|
||||||
|
throw new UnauthorizedAccessException("Player does not belong to authenticated user");
|
||||||
|
}
|
||||||
|
|
||||||
// Lobby-Verwaltung
|
// Lobby-Verwaltung
|
||||||
public async Task CreateLobby(string username)
|
public async Task CreateLobby()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var game = await _gameService.CreateGameAsync(username);
|
var userId = GetAuthenticatedUserId();
|
||||||
|
var game = await _gameService.CreateGameAsync(userId);
|
||||||
var connectionId = Context.ConnectionId;
|
var connectionId = Context.ConnectionId;
|
||||||
await Groups.AddToGroupAsync(connectionId, game.LobbyCode);
|
await Groups.AddToGroupAsync(connectionId, game.LobbyCode);
|
||||||
|
|
||||||
await Clients.Caller.SendAsync("LobbyCreated", new { GameId = game.Id, LobbyCode = game.LobbyCode });
|
await Clients.Caller.SendAsync("LobbyCreated", new { GameId = game.Id, LobbyCode = game.LobbyCode });
|
||||||
_logger.LogInformation($"Lobby {game.LobbyCode} created by {username}");
|
_logger.LogInformation($"Lobby {game.LobbyCode} created by user {userId}");
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
await Clients.Caller.SendAsync("Error", new { Message = "Unauthorized" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -393,17 +484,22 @@ public class GameHub : Hub
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task JoinLobby(string lobbyCode, string username)
|
public async Task JoinLobby(string lobbyCode)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var game = await _gameService.JoinGameAsync(lobbyCode, username);
|
var userId = GetAuthenticatedUserId();
|
||||||
|
var game = await _gameService.JoinGameAsync(lobbyCode, userId);
|
||||||
var connectionId = Context.ConnectionId;
|
var connectionId = Context.ConnectionId;
|
||||||
await Groups.AddToGroupAsync(connectionId, lobbyCode);
|
await Groups.AddToGroupAsync(connectionId, lobbyCode);
|
||||||
|
|
||||||
var gameState = await _gameService.GetGameStateAsync(game.Id);
|
var gameState = await _gameService.GetGameStateAsync(game.Id);
|
||||||
await Clients.Group(lobbyCode).SendAsync("PlayerJoined", gameState);
|
await Clients.Group(lobbyCode).SendAsync("PlayerJoined", gameState);
|
||||||
_logger.LogInformation($"{username} joined lobby {lobbyCode}");
|
_logger.LogInformation($"User {userId} joined lobby {lobbyCode}");
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
await Clients.Caller.SendAsync("Error", new { Message = "Unauthorized" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -773,19 +869,43 @@ public class GameService : IGameService
|
|||||||
|
|
||||||
var builder = WebApplicationBuilder.CreateBuilder(args);
|
var builder = WebApplicationBuilder.CreateBuilder(args);
|
||||||
|
|
||||||
// Datenbankverbindung (SQLite für Development)
|
// Datenbankverbindung (SQLite)
|
||||||
builder.Services.AddDbContext<SlipItInDbContext>(options =>
|
builder.Services.AddDbContext<SlipItInDbContext>(options =>
|
||||||
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")
|
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")
|
||||||
?? "Data Source=slipitIn.db"));
|
?? "Data Source=slipitIn.db"));
|
||||||
|
|
||||||
|
// IDbContextFactory für SignalR-Isolation
|
||||||
|
builder.Services.AddDbContextFactory<SlipItInDbContext>(options =>
|
||||||
|
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")
|
||||||
|
?? "Data Source=slipitIn.db"));
|
||||||
|
|
||||||
// Services registrieren
|
// Services registrieren
|
||||||
builder.Services.AddScoped<IGameService, GameService>();
|
builder.Services.AddScoped<IGameService, GameService>();
|
||||||
|
|
||||||
// SignalR
|
// JWT Authentication
|
||||||
builder.Services.AddSignalR();
|
builder.Services
|
||||||
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.Authority = "https://your-domain.com"; // oder issuer aus config
|
||||||
|
options.TokenValidationParameters = new()
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// SignalR mit JWT
|
||||||
|
builder.Services.AddSignalR(options =>
|
||||||
|
{
|
||||||
|
options.AddFilter<SignalRJwtAuthorizationFilter>();
|
||||||
|
});
|
||||||
|
|
||||||
// Controller & Services
|
// Controller & Services
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -804,6 +924,106 @@ app.Run();
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### MAUI Client-Services (SlipItIn/Services/)
|
||||||
|
|
||||||
|
#### 1. GameStateService.cs – Zentrale Zustandsverwaltung
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface IGameStateService
|
||||||
|
{
|
||||||
|
GameStateDto? CurrentGameState { get; }
|
||||||
|
PlayerHandDto? CurrentPlayerHand { get; }
|
||||||
|
|
||||||
|
Task ResyncAsync(); // App-Resume oder Reconnect
|
||||||
|
Task UpdateGameStateAsync(GameStateDto state);
|
||||||
|
Task UpdatePlayerHandAsync(PlayerHandDto hand);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GameStateService : IGameStateService
|
||||||
|
{
|
||||||
|
private readonly ISignalRService _signalR;
|
||||||
|
private readonly ILocalStorageService _localStorage;
|
||||||
|
private GameStateDto? _currentGameState;
|
||||||
|
private PlayerHandDto? _currentPlayerHand;
|
||||||
|
|
||||||
|
public GameStateDto? CurrentGameState => _currentGameState;
|
||||||
|
public PlayerHandDto? CurrentPlayerHand => _currentPlayerHand;
|
||||||
|
|
||||||
|
public async Task ResyncAsync()
|
||||||
|
{
|
||||||
|
if (_currentGameState?.GameId > 0)
|
||||||
|
await _signalR.RequestGameStateAsync(_currentGameState.GameId);
|
||||||
|
|
||||||
|
var queuedActions = await _localStorage.GetQueuedActionsAsync();
|
||||||
|
foreach (var action in queuedActions)
|
||||||
|
await _signalR.SendActionAsync(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateGameStateAsync(GameStateDto state)
|
||||||
|
{
|
||||||
|
_currentGameState = state;
|
||||||
|
await _localStorage.SaveGameStateAsync(state);
|
||||||
|
WeakReferenceMessenger.Default.Send(new GameStateChangedMessage(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdatePlayerHandAsync(PlayerHandDto hand)
|
||||||
|
{
|
||||||
|
_currentPlayerHand = hand;
|
||||||
|
await _localStorage.SavePlayerHandAsync(hand);
|
||||||
|
WeakReferenceMessenger.Default.Send(new PlayerHandChangedMessage(hand));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. SignalRService.cs – Mit automatischem Reconnect
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class SignalRService : ISignalRService
|
||||||
|
{
|
||||||
|
private HubConnection? _connection;
|
||||||
|
|
||||||
|
public async Task<bool> ConnectAsync(string token)
|
||||||
|
{
|
||||||
|
_connection = new HubConnectionBuilder()
|
||||||
|
.WithUrl(_hubUrl, options =>
|
||||||
|
{
|
||||||
|
options.AccessTokenProvider = () => Task.FromResult(token);
|
||||||
|
})
|
||||||
|
.WithAutomaticReconnect(new[]
|
||||||
|
{
|
||||||
|
TimeSpan.Zero,
|
||||||
|
TimeSpan.FromSeconds(2),
|
||||||
|
TimeSpan.FromSeconds(10),
|
||||||
|
TimeSpan.FromSeconds(30)
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
_connection.On<GameStateDto>("GameStateUpdated", state =>
|
||||||
|
WeakReferenceMessenger.Default.Send(new GameStateChangedMessage(state)));
|
||||||
|
|
||||||
|
_connection.On<PlayerHandDto>("PlayerHandUpdated", hand =>
|
||||||
|
WeakReferenceMessenger.Default.Send(new PlayerHandChangedMessage(hand)));
|
||||||
|
|
||||||
|
_connection.On<SlipChallengeDto>("ChallengeReceived", challenge =>
|
||||||
|
WeakReferenceMessenger.Default.Send(new ChallengeReceivedMessage(challenge)));
|
||||||
|
|
||||||
|
await _connection.StartAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. MauiProgram.cs – Dependency Injection
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddSingleton<ILocalStorageService, LocalStorageService>();
|
||||||
|
builder.Services.AddSingleton<ISignalRService, SignalRService>();
|
||||||
|
builder.Services.AddSingleton<IGameStateService, GameStateService>();
|
||||||
|
builder.Services.AddSingleton<IApiService, ApiService>();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Testplan für Integrationstests
|
### Testplan für Integrationstests
|
||||||
|
|
||||||
#### Integration Test: Lobby-Workflow
|
#### Integration Test: Lobby-Workflow
|
||||||
@@ -831,4 +1051,18 @@ app.Run();
|
|||||||
#### Integration Test: SignalR-Gruppen
|
#### Integration Test: SignalR-Gruppen
|
||||||
- [ ] Alle Hub-Clients in derselben Lobby-Gruppe erhalten Broadcasts bei Challenges
|
- [ ] Alle Hub-Clients in derselben Lobby-Gruppe erhalten Broadcasts bei Challenges
|
||||||
- [ ] Challenge-Notification wird an Beschuldigen-Spieler gesendet
|
- [ ] Challenge-Notification wird an Beschuldigen-Spieler gesendet
|
||||||
|
|
||||||
|
#### Integration Test: JWT-Auth & Sicherheit
|
||||||
|
- [ ] SignalR-Hub lehnt Requests ohne gültiges JWT ab
|
||||||
|
- [ ] Player-ID Spoofing wird verhindert (User kann nur auf seine Players zugreifen)
|
||||||
|
- [ ] `IDbContextFactory` isoliert DB-Sessions bei parallelen Hub-Aufrufen
|
||||||
|
- [ ] Keine Race Conditions beim Phrase-Transfer zwischen 2 Hub-Clients
|
||||||
|
|
||||||
|
#### Integration Test: MAUI State Restoration
|
||||||
|
- [ ] App-Pause speichert `GameStateDto` lokal
|
||||||
|
- [ ] App-Resume synct Spielzustand mit Server
|
||||||
|
- [ ] Offline-Aktionen werden in Queue gepuffert
|
||||||
|
- [ ] Nach Reconnect werden gepufferte Aktionen abgesendet
|
||||||
|
- [ ] UI zeigt "Syncing..." bis Verbindung wiederhergestellt
|
||||||
|
- [ ] `WeakReferenceMessenger` - keine Memory Leaks bei View-Model Cleanup
|
||||||
- [ ] Disconnect-Handler aktualisiert Spielerstatus
|
- [ ] Disconnect-Handler aktualisiert Spielerstatus
|
||||||
Reference in New Issue
Block a user