Compare commits

..

2 Commits

Author SHA1 Message Date
Tim Krampitz
d29bff17a1 Backend für "Slip It In" mit ASP.NET Core erstellt
- Neues Backend-Projekt mit Entity Framework Core (PostgreSQL) und geteilten Modellen/DTOs angelegt
- Datenbankstruktur und Migrations implementiert (User, Game, Player, Phrase, PlayerCard, GameRound, SlipChallenge)
- SignalR-Hub für Spielkommunikation (Lobby, Spielstart, Kartenvergabe, Slip/Challenge) hinzugefügt
- Spiellogik und DB-Zugriffe im GameService gekapselt
- JWT-Authentifizierung und CORS für Entwicklung konfiguriert
- Projektdateien, NuGet-Abhängigkeiten und .gitignore aktualisiert
- Startkonfiguration und Umgebungsdateien angepasst
2026-07-23 21:39:15 +02:00
Tim Krampitz
c1c6c9cb94 Refinement of Plans 2026-07-22 20:33:06 +02:00
29 changed files with 2764 additions and 24 deletions

5
.gitignore vendored
View File

@@ -360,4 +360,7 @@ MigrationBackup/
.ionide/ .ionide/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd FodyWeavers.xsd
/SlipItIn.Server/slipitIn.db
/SlipItIn.Server/slipitIn.db-shm
/SlipItIn.Server/slipitIn.db-wal

136
Agents/Architecture.md Normal file
View 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

View File

@@ -3,14 +3,18 @@
Dieses Dokument beschreibt die Phasen und Aufgaben zur Implementierung des Spiels "Slip It In" mit .NET MAUI (.NET 10) und einem ASP.NET Core Backend. Dieses Dokument beschreibt die Phasen und Aufgaben zur Implementierung des Spiels "Slip It In" mit .NET MAUI (.NET 10) und einem ASP.NET Core Backend.
## Phase 1: Vorbereitung & Architektur-Setup ## Phase 1: Vorbereitung & Architektur-Setup
- [ ] Erstellung der Verzeichnisstruktur im Workspace - [x] Erstellung der Verzeichnisstruktur im Workspace
- [ ] Aufsetzen der gemeinsamen Datenmodelle (Shared Class Library) - [x] Aufsetzen der gemeinsamen Datenmodelle (Shared Class Library)
- `User`, `Game`, `Phrase`, `PlayerCard` - `User`, `Game`, `Phrase`, `PlayerCard`
- [ ] Initialisierung des ASP.NET Core Web API & SignalR Backends - [x] Initialisierung des ASP.NET Core Web API & SignalR Backends
## 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

View File

@@ -1,5 +1,13 @@
var builder = DistributedApplication.CreateBuilder(args); var builder = DistributedApplication.CreateBuilder(args);
builder.AddProject<Projects.SlipItIn>("slipitin"); var db = builder.AddPostgres("pgsql")
.AddDatabase("postgresdb");
var server = builder.AddProject<Projects.SlipItIn_Server>("server")
.WithReference(db)
.WaitFor(db);
builder.AddProject<Projects.SlipItIn>("slipitin")
.WithReference(server);
builder.Build().Run(); builder.Build().Run();

View File

@@ -1,4 +1,4 @@
<Project Sdk="Aspire.AppHost.Sdk/13.2.4"> <Project Sdk="Aspire.AppHost.Sdk/13.4.6">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
@@ -9,13 +9,16 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.4.6" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.4.6" />
<PackageReference Include="MessagePack" Version="3.1.8" /> <PackageReference Include="MessagePack" Version="3.1.8" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\SlipItIN\SlipItIn.csproj"> <ProjectReference Include="..\SlipItIN\SlipItIn.csproj">
<SetTargetFramework>TargetFramework=net10.0</SetTargetFramework> <SetTargetFramework>TargetFramework=net10.0-windows10.0.19041.0</SetTargetFramework>
</ProjectReference> </ProjectReference>
<ProjectReference Include="..\SlipItIn.Server\SlipItIn.Server.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,97 @@
using Microsoft.EntityFrameworkCore;
using SlipItIn.Shared.Models;
namespace SlipItIn.Server.Data;
public class SlipItInDbContext : DbContext
{
public SlipItInDbContext(DbContextOptions<SlipItInDbContext> options) : base(options) { }
public DbSet<User> Users { get; set; }
public DbSet<Phrase> Phrases { get; set; }
public DbSet<Game> Games { get; set; }
public DbSet<Player> Players { get; set; }
public DbSet<PlayerCard> PlayerCards { get; set; }
public DbSet<GameRound> GameRounds { get; set; }
public DbSet<SlipChallenge> SlipChallenges { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// User
modelBuilder.Entity<User>()
.HasIndex(u => u.Username).IsUnique();
modelBuilder.Entity<User>()
.HasIndex(u => u.Email).IsUnique();
// Game
modelBuilder.Entity<Game>()
.HasIndex(g => g.LobbyCode).IsUnique();
modelBuilder.Entity<Game>()
.HasOne(g => g.Host)
.WithMany()
.HasForeignKey(g => g.HostId)
.OnDelete(DeleteBehavior.Restrict);
// Player
modelBuilder.Entity<Player>()
.HasOne(p => p.User)
.WithMany(u => u.Players)
.HasForeignKey(p => p.UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Player>()
.HasOne(p => p.Game)
.WithMany(g => g.Players)
.HasForeignKey(p => p.GameId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Player>()
.HasIndex(p => new { p.GameId, p.UserId }).IsUnique();
// PlayerCard
modelBuilder.Entity<PlayerCard>()
.HasOne(pc => pc.Player)
.WithMany(p => p.Cards)
.HasForeignKey(pc => pc.PlayerId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<PlayerCard>()
.HasOne(pc => pc.Phrase)
.WithMany(ph => ph.PlayerCards)
.HasForeignKey(pc => pc.PhraseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PlayerCard>()
.HasOne(pc => pc.GameRound)
.WithMany(gr => gr.ActiveCards)
.HasForeignKey(pc => pc.GameRoundId)
.OnDelete(DeleteBehavior.Cascade);
// GameRound
modelBuilder.Entity<GameRound>()
.HasOne(gr => gr.Game)
.WithMany(g => g.Rounds)
.HasForeignKey(gr => gr.GameId)
.OnDelete(DeleteBehavior.Cascade);
// SlipChallenge
modelBuilder.Entity<SlipChallenge>()
.HasOne(sc => sc.GameRound)
.WithMany(gr => gr.Challenges)
.HasForeignKey(sc => sc.GameRoundId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SlipChallenge>()
.HasOne(sc => sc.ChallengingPlayer)
.WithMany()
.HasForeignKey(sc => sc.ChallengingPlayerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SlipChallenge>()
.HasOne(sc => sc.TargetPlayer)
.WithMany()
.HasForeignKey(sc => sc.TargetPlayerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SlipChallenge>()
.HasOne(sc => sc.TargetCard)
.WithMany()
.HasForeignKey(sc => sc.TargetCardId)
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -0,0 +1,370 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using SlipItIn.Server.Data;
using SlipItIn.Server.Services;
using SlipItIn.Shared.DTOs;
using System.Security.Claims;
namespace SlipItIn.Server.Hubs;
public class GameHub : Hub
{
private readonly IGameService _gameService;
private readonly IDbContextFactory<SlipItInDbContext> _contextFactory;
private readonly ILogger<GameHub> _logger;
public GameHub(IGameService gameService, IDbContextFactory<SlipItInDbContext> contextFactory, ILogger<GameHub> logger)
{
_gameService = gameService;
_contextFactory = contextFactory;
_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");
}
/// <summary>
/// Speichert die ConnectionId für einen Spieler in der Datenbank
/// </summary>
private async Task UpdatePlayerConnectionIdAsync(int userId, int gameId, string connectionId)
{
using var context = _contextFactory.CreateDbContext();
var player = await context.Players
.FirstOrDefaultAsync(p => p.UserId == userId && p.GameId == gameId);
if (player != null)
{
player.ConnectionId = connectionId;
await context.SaveChangesAsync();
}
}
/// <summary>
/// Validiert dass der authentifizierte User der Host des Spiels ist
/// </summary>
private async Task ValidateHostAccessAsync(int gameId, int authUserId)
{
using var context = _contextFactory.CreateDbContext();
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId);
if (game?.HostId != authUserId)
throw new UnauthorizedAccessException("Only the host can perform this action");
}
/// <summary>
/// Validiert dass der authentifizierte User der Ziel-Spieler einer Challenge ist
/// </summary>
private async Task<int> ValidateChallengeTargetAccessAsync(int challengeId, int authUserId)
{
using var context = _contextFactory.CreateDbContext();
var challenge = await context.SlipChallenges
.Include(c => c.TargetPlayer)
.FirstOrDefaultAsync(c => c.Id == challengeId);
if (challenge == null)
throw new InvalidOperationException("Challenge not found");
if (challenge.TargetPlayer.UserId != authUserId)
throw new UnauthorizedAccessException("Only the accused player can resolve this challenge");
return challenge.TargetPlayerId;
}
// Lobby-Verwaltung
public async Task CreateLobby()
{
try
{
var userId = GetAuthenticatedUserId();
var connectionId = Context.ConnectionId;
var game = await _gameService.CreateGameAsync(userId, connectionId);
await Groups.AddToGroupAsync(connectionId, game.LobbyCode);
await Clients.Caller.SendAsync("LobbyCreated", new { GameId = game.Id, LobbyCode = game.LobbyCode });
_logger.LogInformation("Lobby {LobbyCode} created by user {UserId}", game.LobbyCode, userId);
}
catch (UnauthorizedAccessException)
{
await Clients.Caller.SendAsync("Error", new { Message = "Unauthorized" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating lobby");
await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while creating the lobby." });
}
}
public async Task JoinLobby(string lobbyCode)
{
try
{
var userId = GetAuthenticatedUserId();
var connectionId = Context.ConnectionId;
var game = await _gameService.JoinGameAsync(lobbyCode, userId, connectionId);
await Groups.AddToGroupAsync(connectionId, lobbyCode);
var gameState = await _gameService.GetGameStateAsync(game.Id);
await Clients.Group(lobbyCode).SendAsync("PlayerJoined", gameState);
_logger.LogInformation("User {UserId} joined lobby {LobbyCode}", userId, lobbyCode);
}
catch (UnauthorizedAccessException)
{
await Clients.Caller.SendAsync("Error", new { Message = "Unauthorized" });
}
catch (InvalidOperationException ex)
{
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error joining lobby");
await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while joining the lobby." });
}
}
public async Task PlayerReady(int gameId, int playerId)
{
try
{
var userId = GetAuthenticatedUserId();
await ValidatePlayerAccessAsync(playerId, userId);
var game = await _gameService.SetPlayerReadyAsync(gameId, playerId);
var gameState = await _gameService.GetGameStateAsync(gameId);
var lobbyCode = game.LobbyCode;
await Clients.Group(lobbyCode).SendAsync("GameStateUpdated", gameState);
}
catch (UnauthorizedAccessException ex)
{
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error setting player ready");
await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while setting player status." });
}
}
// Spiel-Start & Kartenvergabe
public async Task StartGame(int gameId)
{
try
{
// Nur der Host darf das Spiel starten
var userId = GetAuthenticatedUserId();
await ValidateHostAccessAsync(gameId, userId);
var game = await _gameService.StartGameAsync(gameId);
var cards = await _gameService.DealCardsAsync(gameId);
var lobbyCode = game.LobbyCode;
await Clients.Group(lobbyCode).SendAsync("GameStarted", new { GameId = gameId });
// Alle Spieler und ihre ConnectionIds in einem Batch laden (Optimierung)
using var context = _contextFactory.CreateDbContext();
var playerIds = cards.Select(c => c.PlayerId).Distinct().ToList();
var players = await context.Players
.Where(p => playerIds.Contains(p.Id))
.ToDictionaryAsync(p => p.Id, p => p.ConnectionId);
// Jeder Spieler erhält nur SEINE eigenen Karten
foreach (var playerCards in cards.GroupBy(c => c.PlayerId))
{
var hand = await _gameService.GetPlayerHandAsync(playerCards.Key);
if (players.TryGetValue(playerCards.Key, out var connectionId) && connectionId != null)
{
await Clients.Client(connectionId).SendAsync("PlayerHandUpdated", hand);
}
}
_logger.LogInformation("Game {GameId} started by user {UserId}", gameId, userId);
}
catch (UnauthorizedAccessException)
{
await Clients.Caller.SendAsync("Error", new { Message = "Only the host can start the game." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting game");
await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while starting the game." });
}
}
// Slip-Mechaniken
public async Task SubmitSlip(int gameId, int playerId, int cardId)
{
try
{
var userId = GetAuthenticatedUserId();
await ValidatePlayerAccessAsync(playerId, userId);
var result = await _gameService.SubmitSlipAsync(gameId, playerId, cardId);
var game = await _gameService.GetGameAsync(gameId);
await Clients.Group(game.LobbyCode).SendAsync("SlipSubmitted", new
{
PlayerId = playerId,
CardId = cardId,
Success = result
});
}
catch (UnauthorizedAccessException ex)
{
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error submitting slip");
await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while submitting slip." });
}
}
public async Task ChallengeSlip(int gameId, int challengingPlayerId, int targetCardId)
{
try
{
var userId = GetAuthenticatedUserId();
await ValidatePlayerAccessAsync(challengingPlayerId, userId);
var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetCardId);
var game = await _gameService.GetGameAsync(gameId);
var challengeDto = new SlipChallengeDto
{
ChallengeId = challenge.Id,
ChallengingPlayerId = challengingPlayerId,
TargetPlayerId = challenge.TargetPlayerId,
TargetCardId = targetCardId,
Status = challenge.Status.ToString(),
CreatedAt = challenge.CreatedAt
};
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)
{
await Clients.Client(targetPlayer.ConnectionId).SendAsync("ChallengeReceived", challengeDto);
}
}
catch (UnauthorizedAccessException ex)
{
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error challenging slip");
await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while challenging slip." });
}
}
public async Task ResolveChallenge(int challengeId, bool approved)
{
try
{
var userId = GetAuthenticatedUserId();
await ValidateChallengeTargetAccessAsync(challengeId, userId);
var result = await _gameService.ResolveChallengeAsync(challengeId, approved);
var game = await _gameService.GetGameAsync(result.GameRound.GameId);
await Clients.Group(game.LobbyCode).SendAsync("ChallengeResolved", new
{
ChallengeId = challengeId,
Approved = approved
});
// Aktualisierte Handkarten an betroffene Spieler senden
var challengingHand = await _gameService.GetPlayerHandAsync(result.ChallengingPlayerId);
var targetHand = await _gameService.GetPlayerHandAsync(result.TargetPlayerId);
using var context = _contextFactory.CreateDbContext();
var challengingPlayer = await context.Players.FindAsync(result.ChallengingPlayerId);
var targetPlayer = await context.Players.FindAsync(result.TargetPlayerId);
if (challengingPlayer?.ConnectionId != null)
await Clients.Client(challengingPlayer.ConnectionId).SendAsync("PlayerHandUpdated", challengingHand);
if (targetPlayer?.ConnectionId != null)
await Clients.Client(targetPlayer.ConnectionId).SendAsync("PlayerHandUpdated", targetHand);
}
catch (UnauthorizedAccessException ex)
{
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (InvalidOperationException ex)
{
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error resolving challenge");
await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while resolving challenge." });
}
}
public async Task RequestGameState(int gameId)
{
try
{
var gameState = await _gameService.GetGameStateAsync(gameId);
await Clients.Caller.SendAsync("GameStateUpdated", gameState);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error requesting game state");
await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while requesting game state." });
}
}
public override async Task OnConnectedAsync()
{
_logger.LogInformation("Client {ConnectionId} connected", Context.ConnectionId);
// Speichere ConnectionId beim Player falls User bereits in einem Spiel ist (Lobby oder InProgress)
try
{
var userId = GetAuthenticatedUserId();
using var context = _contextFactory.CreateDbContext();
var player = await context.Players
.FirstOrDefaultAsync(p => p.UserId == userId && (p.Game.Status == Shared.Models.GameStatus.InProgress || p.Game.Status == Shared.Models.GameStatus.Lobby));
if (player != null)
{
player.ConnectionId = Context.ConnectionId;
await context.SaveChangesAsync();
}
}
catch { /* Ignorieren falls nicht authentifiziert */ }
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("Client {ConnectionId} disconnected", Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}

View File

@@ -0,0 +1,432 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using SlipItIn.Server.Data;
#nullable disable
namespace SlipItIn.Server.Migrations
{
[DbContext(typeof(SlipItInDbContext))]
[Migration("20260723193505_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("SlipItIn.Shared.Models.Game", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("EndedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("HostId")
.HasColumnType("integer");
b.Property<string>("LobbyCode")
.IsRequired()
.HasColumnType("text");
b.Property<int>("MaxPlayers")
.HasColumnType("integer");
b.Property<int>("RoundDurationSeconds")
.HasColumnType("integer");
b.Property<DateTime?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("HostId");
b.HasIndex("LobbyCode")
.IsUnique();
b.ToTable("Games");
});
modelBuilder.Entity("SlipItIn.Shared.Models.GameRound", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("EndedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("GameId")
.HasColumnType("integer");
b.Property<int>("RoundNumber")
.HasColumnType("integer");
b.Property<DateTime>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("GameId");
b.ToTable("GameRounds");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Phrase", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatorId")
.HasColumnType("integer");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.ToTable("Phrases");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Player", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ConnectionId")
.HasColumnType("text");
b.Property<int>("FailedSlips")
.HasColumnType("integer");
b.Property<int>("GameId")
.HasColumnType("integer");
b.Property<bool>("IsReady")
.HasColumnType("boolean");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Score")
.HasColumnType("integer");
b.Property<int>("SuccessfulSlips")
.HasColumnType("integer");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("GameId", "UserId")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("SlipItIn.Shared.Models.PlayerCard", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("AssignedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("GameRoundId")
.HasColumnType("integer");
b.Property<bool>("IsUsed")
.HasColumnType("boolean");
b.Property<int>("PhraseId")
.HasColumnType("integer");
b.Property<int>("PlayerId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("GameRoundId");
b.HasIndex("PhraseId");
b.HasIndex("PlayerId");
b.ToTable("PlayerCards");
});
modelBuilder.Entity("SlipItIn.Shared.Models.SlipChallenge", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ChallengingPlayerId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("GameRoundId")
.HasColumnType("integer");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("TargetCardId")
.HasColumnType("integer");
b.Property<int>("TargetPlayerId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChallengingPlayerId");
b.HasIndex("GameRoundId");
b.HasIndex("TargetCardId");
b.HasIndex("TargetPlayerId");
b.ToTable("SlipChallenges");
});
modelBuilder.Entity("SlipItIn.Shared.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Game", b =>
{
b.HasOne("SlipItIn.Shared.Models.User", "Host")
.WithMany()
.HasForeignKey("HostId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Host");
});
modelBuilder.Entity("SlipItIn.Shared.Models.GameRound", b =>
{
b.HasOne("SlipItIn.Shared.Models.Game", "Game")
.WithMany("Rounds")
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Game");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Phrase", b =>
{
b.HasOne("SlipItIn.Shared.Models.User", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Player", b =>
{
b.HasOne("SlipItIn.Shared.Models.Game", "Game")
.WithMany("Players")
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SlipItIn.Shared.Models.User", "User")
.WithMany("Players")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Game");
b.Navigation("User");
});
modelBuilder.Entity("SlipItIn.Shared.Models.PlayerCard", b =>
{
b.HasOne("SlipItIn.Shared.Models.GameRound", "GameRound")
.WithMany("ActiveCards")
.HasForeignKey("GameRoundId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SlipItIn.Shared.Models.Phrase", "Phrase")
.WithMany("PlayerCards")
.HasForeignKey("PhraseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SlipItIn.Shared.Models.Player", "Player")
.WithMany("Cards")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("GameRound");
b.Navigation("Phrase");
b.Navigation("Player");
});
modelBuilder.Entity("SlipItIn.Shared.Models.SlipChallenge", b =>
{
b.HasOne("SlipItIn.Shared.Models.Player", "ChallengingPlayer")
.WithMany()
.HasForeignKey("ChallengingPlayerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SlipItIn.Shared.Models.GameRound", "GameRound")
.WithMany("Challenges")
.HasForeignKey("GameRoundId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SlipItIn.Shared.Models.PlayerCard", "TargetCard")
.WithMany()
.HasForeignKey("TargetCardId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SlipItIn.Shared.Models.Player", "TargetPlayer")
.WithMany()
.HasForeignKey("TargetPlayerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ChallengingPlayer");
b.Navigation("GameRound");
b.Navigation("TargetCard");
b.Navigation("TargetPlayer");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Game", b =>
{
b.Navigation("Players");
b.Navigation("Rounds");
});
modelBuilder.Entity("SlipItIn.Shared.Models.GameRound", b =>
{
b.Navigation("ActiveCards");
b.Navigation("Challenges");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Phrase", b =>
{
b.Navigation("PlayerCards");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Player", b =>
{
b.Navigation("Cards");
});
modelBuilder.Entity("SlipItIn.Shared.Models.User", b =>
{
b.Navigation("Players");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,319 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SlipItIn.Server.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Username = table.Column<string>(type: "text", nullable: false),
Email = table.Column<string>(type: "text", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Games",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
LobbyCode = table.Column<string>(type: "text", nullable: false),
HostId = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
EndedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
MaxPlayers = table.Column<int>(type: "integer", nullable: false),
RoundDurationSeconds = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Games", x => x.Id);
table.ForeignKey(
name: "FK_Games_Users_HostId",
column: x => x.HostId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Phrases",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Text = table.Column<string>(type: "text", nullable: false),
CreatorId = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Phrases", x => x.Id);
table.ForeignKey(
name: "FK_Phrases_Users_CreatorId",
column: x => x.CreatorId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "GameRounds",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
GameId = table.Column<int>(type: "integer", nullable: false),
RoundNumber = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
EndedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_GameRounds", x => x.Id);
table.ForeignKey(
name: "FK_GameRounds_Games_GameId",
column: x => x.GameId,
principalTable: "Games",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Players",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<int>(type: "integer", nullable: false),
GameId = table.Column<int>(type: "integer", nullable: false),
Score = table.Column<int>(type: "integer", nullable: false),
SuccessfulSlips = table.Column<int>(type: "integer", nullable: false),
FailedSlips = table.Column<int>(type: "integer", nullable: false),
JoinedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsReady = table.Column<bool>(type: "boolean", nullable: false),
ConnectionId = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Players", x => x.Id);
table.ForeignKey(
name: "FK_Players_Games_GameId",
column: x => x.GameId,
principalTable: "Games",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Players_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PlayerCards",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PlayerId = table.Column<int>(type: "integer", nullable: false),
PhraseId = table.Column<int>(type: "integer", nullable: false),
GameRoundId = table.Column<int>(type: "integer", nullable: false),
IsUsed = table.Column<bool>(type: "boolean", nullable: false),
AssignedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlayerCards", x => x.Id);
table.ForeignKey(
name: "FK_PlayerCards_GameRounds_GameRoundId",
column: x => x.GameRoundId,
principalTable: "GameRounds",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlayerCards_Phrases_PhraseId",
column: x => x.PhraseId,
principalTable: "Phrases",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PlayerCards_Players_PlayerId",
column: x => x.PlayerId,
principalTable: "Players",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SlipChallenges",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
GameRoundId = table.Column<int>(type: "integer", nullable: false),
ChallengingPlayerId = table.Column<int>(type: "integer", nullable: false),
TargetPlayerId = table.Column<int>(type: "integer", nullable: false),
TargetCardId = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ResolvedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SlipChallenges", x => x.Id);
table.ForeignKey(
name: "FK_SlipChallenges_GameRounds_GameRoundId",
column: x => x.GameRoundId,
principalTable: "GameRounds",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SlipChallenges_PlayerCards_TargetCardId",
column: x => x.TargetCardId,
principalTable: "PlayerCards",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SlipChallenges_Players_ChallengingPlayerId",
column: x => x.ChallengingPlayerId,
principalTable: "Players",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SlipChallenges_Players_TargetPlayerId",
column: x => x.TargetPlayerId,
principalTable: "Players",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_GameRounds_GameId",
table: "GameRounds",
column: "GameId");
migrationBuilder.CreateIndex(
name: "IX_Games_HostId",
table: "Games",
column: "HostId");
migrationBuilder.CreateIndex(
name: "IX_Games_LobbyCode",
table: "Games",
column: "LobbyCode",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Phrases_CreatorId",
table: "Phrases",
column: "CreatorId");
migrationBuilder.CreateIndex(
name: "IX_PlayerCards_GameRoundId",
table: "PlayerCards",
column: "GameRoundId");
migrationBuilder.CreateIndex(
name: "IX_PlayerCards_PhraseId",
table: "PlayerCards",
column: "PhraseId");
migrationBuilder.CreateIndex(
name: "IX_PlayerCards_PlayerId",
table: "PlayerCards",
column: "PlayerId");
migrationBuilder.CreateIndex(
name: "IX_Players_GameId_UserId",
table: "Players",
columns: new[] { "GameId", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Players_UserId",
table: "Players",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_SlipChallenges_ChallengingPlayerId",
table: "SlipChallenges",
column: "ChallengingPlayerId");
migrationBuilder.CreateIndex(
name: "IX_SlipChallenges_GameRoundId",
table: "SlipChallenges",
column: "GameRoundId");
migrationBuilder.CreateIndex(
name: "IX_SlipChallenges_TargetCardId",
table: "SlipChallenges",
column: "TargetCardId");
migrationBuilder.CreateIndex(
name: "IX_SlipChallenges_TargetPlayerId",
table: "SlipChallenges",
column: "TargetPlayerId");
migrationBuilder.CreateIndex(
name: "IX_Users_Email",
table: "Users",
column: "Email",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_Username",
table: "Users",
column: "Username",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SlipChallenges");
migrationBuilder.DropTable(
name: "PlayerCards");
migrationBuilder.DropTable(
name: "GameRounds");
migrationBuilder.DropTable(
name: "Phrases");
migrationBuilder.DropTable(
name: "Players");
migrationBuilder.DropTable(
name: "Games");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@@ -0,0 +1,429 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using SlipItIn.Server.Data;
#nullable disable
namespace SlipItIn.Server.Migrations
{
[DbContext(typeof(SlipItInDbContext))]
partial class SlipItInDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("SlipItIn.Shared.Models.Game", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("EndedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("HostId")
.HasColumnType("integer");
b.Property<string>("LobbyCode")
.IsRequired()
.HasColumnType("text");
b.Property<int>("MaxPlayers")
.HasColumnType("integer");
b.Property<int>("RoundDurationSeconds")
.HasColumnType("integer");
b.Property<DateTime?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("HostId");
b.HasIndex("LobbyCode")
.IsUnique();
b.ToTable("Games");
});
modelBuilder.Entity("SlipItIn.Shared.Models.GameRound", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("EndedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("GameId")
.HasColumnType("integer");
b.Property<int>("RoundNumber")
.HasColumnType("integer");
b.Property<DateTime>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("GameId");
b.ToTable("GameRounds");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Phrase", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatorId")
.HasColumnType("integer");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatorId");
b.ToTable("Phrases");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Player", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ConnectionId")
.HasColumnType("text");
b.Property<int>("FailedSlips")
.HasColumnType("integer");
b.Property<int>("GameId")
.HasColumnType("integer");
b.Property<bool>("IsReady")
.HasColumnType("boolean");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Score")
.HasColumnType("integer");
b.Property<int>("SuccessfulSlips")
.HasColumnType("integer");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("GameId", "UserId")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("SlipItIn.Shared.Models.PlayerCard", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("AssignedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("GameRoundId")
.HasColumnType("integer");
b.Property<bool>("IsUsed")
.HasColumnType("boolean");
b.Property<int>("PhraseId")
.HasColumnType("integer");
b.Property<int>("PlayerId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("GameRoundId");
b.HasIndex("PhraseId");
b.HasIndex("PlayerId");
b.ToTable("PlayerCards");
});
modelBuilder.Entity("SlipItIn.Shared.Models.SlipChallenge", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ChallengingPlayerId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("GameRoundId")
.HasColumnType("integer");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("TargetCardId")
.HasColumnType("integer");
b.Property<int>("TargetPlayerId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChallengingPlayerId");
b.HasIndex("GameRoundId");
b.HasIndex("TargetCardId");
b.HasIndex("TargetPlayerId");
b.ToTable("SlipChallenges");
});
modelBuilder.Entity("SlipItIn.Shared.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Game", b =>
{
b.HasOne("SlipItIn.Shared.Models.User", "Host")
.WithMany()
.HasForeignKey("HostId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Host");
});
modelBuilder.Entity("SlipItIn.Shared.Models.GameRound", b =>
{
b.HasOne("SlipItIn.Shared.Models.Game", "Game")
.WithMany("Rounds")
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Game");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Phrase", b =>
{
b.HasOne("SlipItIn.Shared.Models.User", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Player", b =>
{
b.HasOne("SlipItIn.Shared.Models.Game", "Game")
.WithMany("Players")
.HasForeignKey("GameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SlipItIn.Shared.Models.User", "User")
.WithMany("Players")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Game");
b.Navigation("User");
});
modelBuilder.Entity("SlipItIn.Shared.Models.PlayerCard", b =>
{
b.HasOne("SlipItIn.Shared.Models.GameRound", "GameRound")
.WithMany("ActiveCards")
.HasForeignKey("GameRoundId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SlipItIn.Shared.Models.Phrase", "Phrase")
.WithMany("PlayerCards")
.HasForeignKey("PhraseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SlipItIn.Shared.Models.Player", "Player")
.WithMany("Cards")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("GameRound");
b.Navigation("Phrase");
b.Navigation("Player");
});
modelBuilder.Entity("SlipItIn.Shared.Models.SlipChallenge", b =>
{
b.HasOne("SlipItIn.Shared.Models.Player", "ChallengingPlayer")
.WithMany()
.HasForeignKey("ChallengingPlayerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SlipItIn.Shared.Models.GameRound", "GameRound")
.WithMany("Challenges")
.HasForeignKey("GameRoundId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SlipItIn.Shared.Models.PlayerCard", "TargetCard")
.WithMany()
.HasForeignKey("TargetCardId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SlipItIn.Shared.Models.Player", "TargetPlayer")
.WithMany()
.HasForeignKey("TargetPlayerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ChallengingPlayer");
b.Navigation("GameRound");
b.Navigation("TargetCard");
b.Navigation("TargetPlayer");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Game", b =>
{
b.Navigation("Players");
b.Navigation("Rounds");
});
modelBuilder.Entity("SlipItIn.Shared.Models.GameRound", b =>
{
b.Navigation("ActiveCards");
b.Navigation("Challenges");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Phrase", b =>
{
b.Navigation("PlayerCards");
});
modelBuilder.Entity("SlipItIn.Shared.Models.Player", b =>
{
b.Navigation("Cards");
});
modelBuilder.Entity("SlipItIn.Shared.Models.User", b =>
{
b.Navigation("Players");
});
#pragma warning restore 612, 618
}
}
}

103
SlipItIn.Server/Program.cs Normal file
View File

@@ -0,0 +1,103 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using SlipItIn.Server.Data;
using SlipItIn.Server.Hubs;
using SlipItIn.Server.Services;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
// Aspire Service Defaults (OpenTelemetry, Health Checks, Service Discovery)
builder.AddServiceDefaults();
builder.AddNpgsqlDataSource(connectionName: "postgresdb");
builder.Services.AddDbContextFactory<SlipItInDbContext>(options => options.UseNpgsql());
// Services registrieren
builder.Services.AddTransient<IGameService, GameService>();
// JWT Authentication
var jwtKey = builder.Configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "SlipItInServer";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "SlipItInClient";
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
};
// SignalR-Support für JWT in Query String
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
// SignalR
builder.Services.AddSignalR();
// CORS für Development
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// Controller & OpenAPI
builder.Services.AddControllers();
builder.Services.AddOpenApi();
var app = builder.Build();
// Aspire Default Endpoints (Health Checks)
app.MapDefaultEndpoints();
// Migrations anwenden
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SlipItInDbContext>();
db.Database.Migrate();
}
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseCors("AllowAll");
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<GameHub>("/hubs/game");
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5234",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7274;http://localhost:5234",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,265 @@
using Microsoft.EntityFrameworkCore;
using SlipItIn.Server.Data;
using SlipItIn.Shared.DTOs;
using SlipItIn.Shared.Models;
namespace SlipItIn.Server.Services;
public class GameService : IGameService
{
private readonly IDbContextFactory<SlipItInDbContext> _contextFactory;
private readonly ILogger<GameService> _logger;
public GameService(IDbContextFactory<SlipItInDbContext> contextFactory, ILogger<GameService> logger)
{
_contextFactory = contextFactory;
_logger = logger;
}
public async Task<Game> CreateGameAsync(int hostUserId, string? connectionId = null)
{
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 game = new Game
{
LobbyCode = lobbyCode,
HostId = host.Id,
Status = GameStatus.Lobby
};
context.Games.Add(game);
// Host wird automatisch als erster Spieler hinzugefügt
var hostPlayer = new Player { UserId = host.Id, GameId = game.Id, ConnectionId = connectionId };
context.Players.Add(hostPlayer);
await context.SaveChangesAsync();
return game;
}
public async Task<Game> JoinGameAsync(string lobbyCode, int userId, string? connectionId = null)
{
using var context = _contextFactory.CreateDbContext();
var game = await context.Games
.Include(g => g.Players)
.FirstOrDefaultAsync(g => g.LobbyCode == lobbyCode && g.Status == GameStatus.Lobby) ?? throw new InvalidOperationException("Game not found or already started");
if (game.Players.Count >= game.MaxPlayers) throw new InvalidOperationException("Game is full");
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == userId) ?? throw new InvalidOperationException("User not found");
// Prüfen ob User bereits im Spiel ist
if (game.Players.Any(p => p.UserId == userId))
throw new InvalidOperationException("User already in game");
var player = new Player { UserId = user.Id, GameId = game.Id, ConnectionId = connectionId };
context.Players.Add(player);
await context.SaveChangesAsync();
return game;
}
public async Task<Game> StartGameAsync(int gameId)
{
using var context = _contextFactory.CreateDbContext();
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
game.Status = GameStatus.InProgress;
game.StartedAt = DateTime.UtcNow;
var round = new GameRound { GameId = gameId, RoundNumber = 1 };
context.GameRounds.Add(round);
await context.SaveChangesAsync();
return game;
}
public async Task<Game> SetPlayerReadyAsync(int gameId, int playerId)
{
using var context = _contextFactory.CreateDbContext();
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId) ?? throw new InvalidOperationException("Player not found");
player.IsReady = true;
await context.SaveChangesAsync();
return await context.Games.FirstOrDefaultAsync(g => g.Id == gameId)
?? throw new InvalidOperationException("Game not found");
}
public async Task<List<PlayerCard>> DealCardsAsync(int gameId)
{
using var context = _contextFactory.CreateDbContext();
var players = await context.Players.Where(p => p.GameId == gameId).ToListAsync();
var currentRound = await context.GameRounds
.Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Waiting)
.FirstOrDefaultAsync() ?? throw new InvalidOperationException("No active round found");
var allPhrases = await context.Phrases.Where(p => p.IsActive).ToListAsync();
if (allPhrases.Count < 5) throw new InvalidOperationException("Not enough phrases in database");
var dealtCards = new List<PlayerCard>();
foreach (var player in players)
{
var selectedPhrases = allPhrases.OrderBy(_ => Random.Shared.Next()).Take(5);
foreach (var phrase in selectedPhrases)
{
var card = new PlayerCard
{
PlayerId = player.Id,
PhraseId = phrase.Id,
GameRoundId = currentRound.Id,
IsUsed = false
};
context.PlayerCards.Add(card);
dealtCards.Add(card);
}
}
currentRound.Status = RoundStatus.Active;
await context.SaveChangesAsync();
return dealtCards;
}
public async Task<bool> SubmitSlipAsync(int gameId, int playerId, int cardId)
{
using var context = _contextFactory.CreateDbContext();
var card = await context.PlayerCards
.Include(pc => pc.Player)
.FirstOrDefaultAsync(pc => pc.Id == cardId) ?? throw new InvalidOperationException("Card not found");
if (card.PlayerId != playerId) throw new UnauthorizedAccessException("Card does not belong to player");
card.IsUsed = true;
await context.SaveChangesAsync();
return true;
}
public async Task<SlipChallenge> CreateChallengeAsync(int gameId, int challengingPlayerId, int targetCardId)
{
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 challenge = new SlipChallenge
{
GameRoundId = currentRound.Id,
ChallengingPlayerId = challengingPlayerId,
TargetPlayerId = targetCard.PlayerId,
TargetCardId = targetCardId,
Status = ChallengeStatus.Pending
};
context.SlipChallenges.Add(challenge);
await context.SaveChangesAsync();
return challenge;
}
public async Task<SlipChallenge> ResolveChallengeAsync(int challengeId, bool approved)
{
using var context = _contextFactory.CreateDbContext();
var challenge = await context.SlipChallenges
.Include(sc => sc.GameRound)
.Include(sc => sc.TargetCard)
.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 (!approved)
{
// FALSCHE BESCHULDIGUNG: Beschuldiger erhält die Phrase des Beschuldigten
var targetCard = challenge.TargetCard;
targetCard.PlayerId = challenge.ChallengingPlayerId;
context.PlayerCards.Update(targetCard);
}
// Wenn approved: Phrase bleibt bei Beschuldigtem (= Phrase war tatsächlich ein SlipIn)
challenge.Status = approved ? ChallengeStatus.Approved : ChallengeStatus.Rejected;
challenge.ResolvedAt = DateTime.UtcNow;
await context.SaveChangesAsync();
return challenge;
}
public async Task<GameStateDto> GetGameStateAsync(int gameId)
{
using var context = _contextFactory.CreateDbContext();
var game = await context.Games
.Include(g => g.Players)
.ThenInclude(p => p.User)
.Include(g => g.Players)
.ThenInclude(p => p.Cards)
.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
var playerInfos = game.Players.Select(p => new PlayerInfoDto
{
PlayerId = p.Id,
Username = p.User.Username,
Score = p.Score,
IsReady = p.IsReady,
CardCount = p.Cards.Count // Nur Anzahl, nicht die Karten selbst!
}).ToList();
return new GameStateDto
{
GameId = game.Id,
LobbyCode = game.LobbyCode,
Status = game.Status.ToString(),
Players = playerInfos,
CurrentRound = game.Rounds?.Count ?? 0
};
}
public async Task<PlayerHandDto> GetPlayerHandAsync(int playerId)
{
using var context = _contextFactory.CreateDbContext();
var cards = await context.PlayerCards
.Include(pc => pc.Phrase)
.Where(pc => pc.PlayerId == playerId)
.Select(pc => new PlayerCardDto
{
CardId = pc.Id,
PhraseId = pc.PhraseId,
Text = pc.Phrase.Text,
IsUsed = pc.IsUsed
})
.ToListAsync();
return new PlayerHandDto
{
PlayerId = playerId,
Cards = cards
};
}
public async Task<Game> GetGameAsync(int gameId)
{
using var context = _contextFactory.CreateDbContext();
return await context.Games.FirstOrDefaultAsync(g => g.Id == gameId)
?? throw new InvalidOperationException("Game not found");
}
private string GenerateLobbyCode()
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var code = new string([.. Enumerable.Range(0, 6).Select(_ => chars[Random.Shared.Next(chars.Length)])]);
return code;
}
}

View File

@@ -0,0 +1,19 @@
using SlipItIn.Shared.DTOs;
using SlipItIn.Shared.Models;
namespace SlipItIn.Server.Services;
public interface IGameService
{
Task<Game> CreateGameAsync(int hostUserId, string? connectionId = null);
Task<Game> JoinGameAsync(string lobbyCode, int userId, string? connectionId = null);
Task<Game> StartGameAsync(int gameId);
Task<Game> SetPlayerReadyAsync(int gameId, int playerId);
Task<List<PlayerCard>> DealCardsAsync(int gameId);
Task<bool> SubmitSlipAsync(int gameId, int playerId, int cardId);
Task<SlipChallenge> CreateChallengeAsync(int gameId, int challengingPlayerId, int targetCardId);
Task<SlipChallenge> ResolveChallengeAsync(int challengeId, bool approved);
Task<GameStateDto> GetGameStateAsync(int gameId);
Task<PlayerHandDto> GetPlayerHandAsync(int playerId);
Task<Game> GetGameAsync(int gameId);
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql" Version="13.4.6" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SlipItIn.ServiceDefaults\SlipItIn.ServiceDefaults.csproj" />
<ProjectReference Include="..\SlipItIn.Shared\SlipItIn.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@SlipItIn.Server_HostAddress = http://localhost:5234
GET {{SlipItIn.Server_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,19 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"postgresdb": "Server=(localdb)\\postgres;Database=postgresdb"
},
"Jwt": {
"Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
"Issuer": "SlipItInServer",
"Audience": "SlipItInClient",
"ExpirationMinutes": 1440
}
}

View File

@@ -0,0 +1,47 @@
namespace SlipItIn.Shared.DTOs;
public class GameStateDto
{
public int GameId { get; set; }
public string LobbyCode { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public List<PlayerInfoDto> Players { get; set; } = [];
public int CurrentRound { get; set; }
public int RoundTimeRemaining { get; set; }
}
public class PlayerInfoDto
{
// Öffentliche Daten (für alle sichtbar)
public int PlayerId { get; set; }
public string Username { get; set; } = string.Empty;
public int Score { get; set; }
public bool IsReady { get; set; }
public int CardCount { get; set; } // Anzahl der Karten (nicht die Karten selbst!)
}
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 PhraseId { get; set; }
public string Text { get; set; } = string.Empty;
public bool IsUsed { get; set; }
}
public class SlipChallengeDto
{
public int ChallengeId { get; set; }
public int ChallengingPlayerId { get; set; }
public string ChallengingPlayerName { get; set; } = string.Empty;
public int TargetPlayerId { get; set; }
public int TargetCardId { get; set; }
public string Status { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace SlipItIn.Shared.Models;
public enum GameStatus
{
Lobby,
InProgress,
Completed,
Cancelled
}
public class Game
{
public int Id { get; set; }
public string LobbyCode { get; set; } = string.Empty; // 4-6 stelliger Code
public int HostId { get; set; }
public GameStatus Status { get; set; } = GameStatus.Lobby;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? StartedAt { get; set; }
public DateTime? EndedAt { get; set; }
public int MaxPlayers { get; set; } = 8;
public int RoundDurationSeconds { get; set; } = 45;
// Navigation
public User Host { get; set; } = null!;
public ICollection<Player> Players { get; set; } = [];
public ICollection<GameRound> Rounds { get; set; } = [];
}

View File

@@ -0,0 +1,24 @@
namespace SlipItIn.Shared.Models;
public enum RoundStatus
{
Waiting,
Active,
Resolving,
Completed
}
public class GameRound
{
public int Id { get; set; }
public int GameId { get; set; }
public int RoundNumber { get; set; }
public RoundStatus Status { get; set; } = RoundStatus.Waiting;
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
public DateTime? EndedAt { get; set; }
// Navigation
public Game Game { get; set; } = null!;
public ICollection<PlayerCard> ActiveCards { get; set; } = [];
public ICollection<SlipChallenge> Challenges { get; set; } = [];
}

View File

@@ -0,0 +1,14 @@
namespace SlipItIn.Shared.Models;
public class Phrase
{
public int Id { get; set; }
public string Text { get; set; } = string.Empty;
public int CreatorId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public bool IsActive { get; set; } = true;
// Navigation
public User Creator { get; set; } = null!;
public ICollection<PlayerCard> PlayerCards { get; set; } = [];
}

View File

@@ -0,0 +1,19 @@
namespace SlipItIn.Shared.Models;
public class Player
{
public int Id { get; set; }
public int UserId { get; set; }
public int GameId { get; set; }
public int Score { get; set; } = 0;
public int SuccessfulSlips { get; set; } = 0;
public int FailedSlips { get; set; } = 0;
public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
public bool IsReady { get; set; } = false;
public string? ConnectionId { get; set; } // SignalR-Verbindungs-ID
// Navigation
public User User { get; set; } = null!;
public Game Game { get; set; } = null!;
public ICollection<PlayerCard> Cards { get; set; } = [];
}

View File

@@ -0,0 +1,16 @@
namespace SlipItIn.Shared.Models;
public class PlayerCard
{
public int Id { get; set; }
public int PlayerId { get; set; }
public int PhraseId { get; set; }
public int GameRoundId { get; set; }
public bool IsUsed { get; set; } = false;
public DateTime AssignedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Player Player { get; set; } = null!;
public Phrase Phrase { get; set; } = null!;
public GameRound GameRound { get; set; } = null!;
}

View File

@@ -0,0 +1,26 @@
namespace SlipItIn.Shared.Models;
public enum ChallengeStatus
{
Pending, // Beschuldigung eingegangen, wartet auf Bestätigung/Ablehnung
Approved, // Beschuldigung war berechtigt → Phrase bleibt beim Beschuldiger
Rejected // Beschuldigung falsch → Beschuldiger erhält die Phrase
}
public class SlipChallenge
{
public int Id { get; set; }
public int GameRoundId { get; set; }
public int ChallengingPlayerId { get; set; } // Wer beschuldigt
public int TargetPlayerId { get; set; } // Wer beschuldigt wird
public int TargetCardId { get; set; } // Welche Phrase wird als falsch behauptet
public ChallengeStatus Status { get; set; } = ChallengeStatus.Pending;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ResolvedAt { get; set; }
// Navigation
public GameRound GameRound { get; set; } = null!;
public Player ChallengingPlayer { get; set; } = null!; // Der Beschuldiger
public Player TargetPlayer { get; set; } = null!; // Der Beschuldigte
public PlayerCard TargetCard { get; set; } = null!; // Die angefochtene Phrase
}

View File

@@ -0,0 +1,15 @@
namespace SlipItIn.Shared.Models;
public class User
{
public int Id { get; set; }
public string Username { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public bool IsActive { get; set; } = true;
// Navigation
public ICollection<Player> Players { get; set; } = [];
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,12 @@
<Solution> <Solution>
<Folder Name="/Projektmappenelemente/">
<File Path="Agents/Architecture.md" />
<File Path="Agents/ProjectPlan.md" />
</Folder>
<Project Path="SlipItIN.AppHost/SlipItIn.AppHost.csproj" /> <Project Path="SlipItIN.AppHost/SlipItIn.AppHost.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/SlipItIn.csproj"> <Project Path="SlipItIN/SlipItIn.csproj">
<Deploy Solution="Debug|*" /> <Deploy Solution="Debug|*" />
</Project> </Project>

View File

@@ -47,6 +47,38 @@
<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'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-ios|AnyCPU'">
<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>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<!-- App Icon --> <!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" /> <MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
@@ -66,8 +98,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.80" /> <PackageReference Include="Microsoft.Maui.Controls" Version="10.0.90" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.10" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="11.0.0-preview.6.26359.118" />
</ItemGroup> </ItemGroup>
</Project> </Project>