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/
# 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.
## Phase 1: Vorbereitung & Architektur-Setup
- [ ] Erstellung der Verzeichnisstruktur im Workspace
- [ ] Aufsetzen der gemeinsamen Datenmodelle (Shared Class Library)
- [x] Erstellung der Verzeichnisstruktur im Workspace
- [x] Aufsetzen der gemeinsamen Datenmodelle (Shared Class Library)
- `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)
- [ ] **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**:
- `GameHub` für Echtzeit-Kommunikation
- 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
- 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)
- [ ] **Infrastruktur**:
- MVVM-Setup mit CommunityToolkit.Mvvm
- Integration von Dependency Injection für Services
- Messenger-Architektur mit `WeakReferenceMessenger` für Event-Entkopplung
- [ ] **Services**:
- `ApiService` für REST-Anfragen
- `SignalRService` für Echtzeit-Events
- `ApiService` für REST-Anfragen (mit JWT-Token aus `SecureStorage`)
- `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**:
- `LoginPage` & `RegisterPage`
- `LobbyPage` (Spielerliste & Start-Button für Host)
- `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)
## 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
- [ ] Integrationstest der SignalR-Kommunikation zwischen mehreren MAUI-Clients
- [ ] 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.
### 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
**Grundkonzept:**
@@ -337,16 +387,25 @@ public class GameStateDto
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 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 PhraseId { get; set; }
public string Text { get; set; } = string.Empty;
public bool IsUsed { get; set; }
}
@@ -356,8 +415,10 @@ 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; }
}
```
@@ -366,25 +427,55 @@ public class SlipChallengeDto
public class GameHub : Hub
{
private readonly IGameService _gameService;
private readonly IDbContextFactory<SlipItInDbContext> _contextFactory;
private readonly ILogger<GameHub> _logger;
public GameHub(IGameService gameService, 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");
}
// Lobby-Verwaltung
public async Task CreateLobby(string username)
public async Task CreateLobby()
{
try
{
var game = await _gameService.CreateGameAsync(username);
var userId = GetAuthenticatedUserId();
var game = await _gameService.CreateGameAsync(userId);
var connectionId = Context.ConnectionId;
await Groups.AddToGroupAsync(connectionId, 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)
{
@@ -393,17 +484,22 @@ public class GameHub : Hub
}
}
public async Task JoinLobby(string lobbyCode, string username)
public async Task JoinLobby(string lobbyCode)
{
try
{
var game = await _gameService.JoinGameAsync(lobbyCode, username);
var userId = GetAuthenticatedUserId();
var game = await _gameService.JoinGameAsync(lobbyCode, userId);
var connectionId = Context.ConnectionId;
await Groups.AddToGroupAsync(connectionId, lobbyCode);
var gameState = await _gameService.GetGameStateAsync(game.Id);
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)
{
@@ -773,19 +869,43 @@ public class GameService : IGameService
var builder = WebApplicationBuilder.CreateBuilder(args);
// Datenbankverbindung (SQLite für Development)
// Datenbankverbindung (SQLite)
builder.Services.AddDbContext<SlipItInDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")
?? "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
builder.Services.AddScoped<IGameService, GameService>();
// SignalR
builder.Services.AddSignalR();
// JWT Authentication
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
builder.Services.AddControllers();
builder.Services.AddAuthorization();
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
#### Integration Test: Lobby-Workflow
@@ -831,4 +1051,18 @@ app.Run();
#### Integration Test: SignalR-Gruppen
- [ ] Alle Hub-Clients in derselben Lobby-Gruppe erhalten Broadcasts bei Challenges
- [ ] 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

View File

@@ -1,5 +1,13 @@
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();

View File

@@ -1,4 +1,4 @@
<Project Sdk="Aspire.AppHost.Sdk/13.2.4">
<Project Sdk="Aspire.AppHost.Sdk/13.4.6">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -9,13 +9,16 @@
</PropertyGroup>
<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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SlipItIN\SlipItIn.csproj">
<SetTargetFramework>TargetFramework=net10.0</SetTargetFramework>
<SetTargetFramework>TargetFramework=net10.0-windows10.0.19041.0</SetTargetFramework>
</ProjectReference>
<ProjectReference Include="..\SlipItIn.Server\SlipItIn.Server.csproj" />
</ItemGroup>
</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>
<Folder Name="/Projektmappenelemente/">
<File Path="Agents/Architecture.md" />
<File Path="Agents/ProjectPlan.md" />
</Folder>
<Project Path="SlipItIN.AppHost/SlipItIn.AppHost.csproj" />
<Project Path="SlipItIn.Server/SlipItIn.Server.csproj" />
<Project Path="SlipItIN.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
<Project Path="SlipItIn.Shared/SlipItIn.Shared.csproj" />
<Project Path="SlipItIN/SlipItIn.csproj">
<Deploy Solution="Debug|*" />
</Project>

View File

@@ -47,6 +47,38 @@
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
</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>
<!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
@@ -66,8 +98,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.80" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.10" />
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.90" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="11.0.0-preview.6.26359.118" />
</ItemGroup>
</Project>