From d29bff17a185bf0652dffb2418b98c5f823c378d Mon Sep 17 00:00:00 2001 From: Tim Krampitz Date: Thu, 23 Jul 2026 21:39:15 +0200 Subject: [PATCH] =?UTF-8?q?Backend=20f=C3=BCr=20"Slip=20It=20In"=20mit=20A?= =?UTF-8?q?SP.NET=20Core=20erstellt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 5 +- Agents/ProjectPlan.md | 6 +- SlipItIn.AppHost/AppHost.cs | 10 +- SlipItIn.AppHost/SlipItIn.AppHost.csproj | 7 +- SlipItIn.Server/Data/SlipItInDbContext.cs | 97 ++++ SlipItIn.Server/Hubs/GameHub.cs | 370 +++++++++++++++ .../20260723193505_InitialCreate.Designer.cs | 432 ++++++++++++++++++ .../20260723193505_InitialCreate.cs | 319 +++++++++++++ .../SlipItInDbContextModelSnapshot.cs | 429 +++++++++++++++++ SlipItIn.Server/Program.cs | 103 +++++ .../Properties/launchSettings.json | 23 + SlipItIn.Server/Services/GameService.cs | 265 +++++++++++ SlipItIn.Server/Services/IGameService.cs | 19 + SlipItIn.Server/SlipItIn.Server.csproj | 27 ++ SlipItIn.Server/SlipItIn.Server.http | 6 + SlipItIn.Server/appsettings.Development.json | 8 + SlipItIn.Server/appsettings.json | 19 + SlipItIn.Shared/DTOs/GameStateDto.cs | 47 ++ SlipItIn.Shared/Models/Game.cs | 27 ++ SlipItIn.Shared/Models/GameRound.cs | 24 + SlipItIn.Shared/Models/Phrase.cs | 14 + SlipItIn.Shared/Models/Player.cs | 19 + SlipItIn.Shared/Models/PlayerCard.cs | 16 + SlipItIn.Shared/Models/SlipChallenge.cs | 26 ++ SlipItIn.Shared/Models/User.cs | 15 + SlipItIn.Shared/SlipItIn.Shared.csproj | 13 + SlipItIn.slnx | 6 + SlipItIn/SlipItIn.csproj | 36 +- 28 files changed, 2379 insertions(+), 9 deletions(-) create mode 100644 SlipItIn.Server/Data/SlipItInDbContext.cs create mode 100644 SlipItIn.Server/Hubs/GameHub.cs create mode 100644 SlipItIn.Server/Migrations/20260723193505_InitialCreate.Designer.cs create mode 100644 SlipItIn.Server/Migrations/20260723193505_InitialCreate.cs create mode 100644 SlipItIn.Server/Migrations/SlipItInDbContextModelSnapshot.cs create mode 100644 SlipItIn.Server/Program.cs create mode 100644 SlipItIn.Server/Properties/launchSettings.json create mode 100644 SlipItIn.Server/Services/GameService.cs create mode 100644 SlipItIn.Server/Services/IGameService.cs create mode 100644 SlipItIn.Server/SlipItIn.Server.csproj create mode 100644 SlipItIn.Server/SlipItIn.Server.http create mode 100644 SlipItIn.Server/appsettings.Development.json create mode 100644 SlipItIn.Server/appsettings.json create mode 100644 SlipItIn.Shared/DTOs/GameStateDto.cs create mode 100644 SlipItIn.Shared/Models/Game.cs create mode 100644 SlipItIn.Shared/Models/GameRound.cs create mode 100644 SlipItIn.Shared/Models/Phrase.cs create mode 100644 SlipItIn.Shared/Models/Player.cs create mode 100644 SlipItIn.Shared/Models/PlayerCard.cs create mode 100644 SlipItIn.Shared/Models/SlipChallenge.cs create mode 100644 SlipItIn.Shared/Models/User.cs create mode 100644 SlipItIn.Shared/SlipItIn.Shared.csproj diff --git a/.gitignore b/.gitignore index 9491a2f..29a8050 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,7 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd +/SlipItIn.Server/slipitIn.db +/SlipItIn.Server/slipitIn.db-shm +/SlipItIn.Server/slipitIn.db-wal diff --git a/Agents/ProjectPlan.md b/Agents/ProjectPlan.md index 78a7764..56b13d7 100644 --- a/Agents/ProjectPlan.md +++ b/Agents/ProjectPlan.md @@ -3,10 +3,10 @@ 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 diff --git a/SlipItIn.AppHost/AppHost.cs b/SlipItIn.AppHost/AppHost.cs index f591e7f..1e99437 100644 --- a/SlipItIn.AppHost/AppHost.cs +++ b/SlipItIn.AppHost/AppHost.cs @@ -1,5 +1,13 @@ var builder = DistributedApplication.CreateBuilder(args); -builder.AddProject("slipitin"); +var db = builder.AddPostgres("pgsql") + .AddDatabase("postgresdb"); + +var server = builder.AddProject("server") + .WithReference(db) + .WaitFor(db); + +builder.AddProject("slipitin") + .WithReference(server); builder.Build().Run(); diff --git a/SlipItIn.AppHost/SlipItIn.AppHost.csproj b/SlipItIn.AppHost/SlipItIn.AppHost.csproj index a687062..f46b357 100644 --- a/SlipItIn.AppHost/SlipItIn.AppHost.csproj +++ b/SlipItIn.AppHost/SlipItIn.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,13 +9,16 @@ + + - TargetFramework=net10.0 + TargetFramework=net10.0-windows10.0.19041.0 + diff --git a/SlipItIn.Server/Data/SlipItInDbContext.cs b/SlipItIn.Server/Data/SlipItInDbContext.cs new file mode 100644 index 0000000..facaa9f --- /dev/null +++ b/SlipItIn.Server/Data/SlipItInDbContext.cs @@ -0,0 +1,97 @@ +using Microsoft.EntityFrameworkCore; +using SlipItIn.Shared.Models; + +namespace SlipItIn.Server.Data; + +public class SlipItInDbContext : DbContext +{ + public SlipItInDbContext(DbContextOptions options) : base(options) { } + + public DbSet Users { get; set; } + public DbSet Phrases { get; set; } + public DbSet Games { get; set; } + public DbSet Players { get; set; } + public DbSet PlayerCards { get; set; } + public DbSet GameRounds { get; set; } + public DbSet SlipChallenges { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // User + modelBuilder.Entity() + .HasIndex(u => u.Username).IsUnique(); + modelBuilder.Entity() + .HasIndex(u => u.Email).IsUnique(); + + // Game + modelBuilder.Entity() + .HasIndex(g => g.LobbyCode).IsUnique(); + modelBuilder.Entity() + .HasOne(g => g.Host) + .WithMany() + .HasForeignKey(g => g.HostId) + .OnDelete(DeleteBehavior.Restrict); + + // Player + modelBuilder.Entity() + .HasOne(p => p.User) + .WithMany(u => u.Players) + .HasForeignKey(p => p.UserId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(p => p.Game) + .WithMany(g => g.Players) + .HasForeignKey(p => p.GameId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasIndex(p => new { p.GameId, p.UserId }).IsUnique(); + + // PlayerCard + modelBuilder.Entity() + .HasOne(pc => pc.Player) + .WithMany(p => p.Cards) + .HasForeignKey(pc => pc.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(pc => pc.Phrase) + .WithMany(ph => ph.PlayerCards) + .HasForeignKey(pc => pc.PhraseId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(pc => pc.GameRound) + .WithMany(gr => gr.ActiveCards) + .HasForeignKey(pc => pc.GameRoundId) + .OnDelete(DeleteBehavior.Cascade); + + // GameRound + modelBuilder.Entity() + .HasOne(gr => gr.Game) + .WithMany(g => g.Rounds) + .HasForeignKey(gr => gr.GameId) + .OnDelete(DeleteBehavior.Cascade); + + // SlipChallenge + modelBuilder.Entity() + .HasOne(sc => sc.GameRound) + .WithMany(gr => gr.Challenges) + .HasForeignKey(sc => sc.GameRoundId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(sc => sc.ChallengingPlayer) + .WithMany() + .HasForeignKey(sc => sc.ChallengingPlayerId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(sc => sc.TargetPlayer) + .WithMany() + .HasForeignKey(sc => sc.TargetPlayerId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(sc => sc.TargetCard) + .WithMany() + .HasForeignKey(sc => sc.TargetCardId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/SlipItIn.Server/Hubs/GameHub.cs b/SlipItIn.Server/Hubs/GameHub.cs new file mode 100644 index 0000000..2809e3f --- /dev/null +++ b/SlipItIn.Server/Hubs/GameHub.cs @@ -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 _contextFactory; + private readonly ILogger _logger; + + public GameHub(IGameService gameService, IDbContextFactory contextFactory, ILogger logger) + { + _gameService = gameService; + _contextFactory = contextFactory; + _logger = logger; + } + + /// + /// Validiert die JWT-Claims des aktuellen Nutzers + /// Wirft Exception wenn User nicht authentifiziert oder Claim fehlt + /// + 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; + } + + /// + /// Sichert ab, dass ein Spieler nur auf seine eigenen Daten zugreift + /// + 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"); + } + + /// + /// Speichert die ConnectionId für einen Spieler in der Datenbank + /// + 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(); + } + } + + /// + /// Validiert dass der authentifizierte User der Host des Spiels ist + /// + 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"); + } + + /// + /// Validiert dass der authentifizierte User der Ziel-Spieler einer Challenge ist + /// + private async Task 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); + } +} diff --git a/SlipItIn.Server/Migrations/20260723193505_InitialCreate.Designer.cs b/SlipItIn.Server/Migrations/20260723193505_InitialCreate.Designer.cs new file mode 100644 index 0000000..96d9fb3 --- /dev/null +++ b/SlipItIn.Server/Migrations/20260723193505_InitialCreate.Designer.cs @@ -0,0 +1,432 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HostId") + .HasColumnType("integer"); + + b.Property("LobbyCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("RoundDurationSeconds") + .HasColumnType("integer"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("RoundNumber") + .HasColumnType("integer"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("GameRounds"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.Phrase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("Phrases"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConnectionId") + .HasColumnType("text"); + + b.Property("FailedSlips") + .HasColumnType("integer"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("IsReady") + .HasColumnType("boolean"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("SuccessfulSlips") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameRoundId") + .HasColumnType("integer"); + + b.Property("IsUsed") + .HasColumnType("boolean"); + + b.Property("PhraseId") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChallengingPlayerId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameRoundId") + .HasColumnType("integer"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TargetCardId") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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 + } + } +} diff --git a/SlipItIn.Server/Migrations/20260723193505_InitialCreate.cs b/SlipItIn.Server/Migrations/20260723193505_InitialCreate.cs new file mode 100644 index 0000000..f3b25bf --- /dev/null +++ b/SlipItIn.Server/Migrations/20260723193505_InitialCreate.cs @@ -0,0 +1,319 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace SlipItIn.Server.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Username = table.Column(type: "text", nullable: false), + Email = table.Column(type: "text", nullable: false), + PasswordHash = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + IsActive = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Games", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + LobbyCode = table.Column(type: "text", nullable: false), + HostId = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + StartedAt = table.Column(type: "timestamp with time zone", nullable: true), + EndedAt = table.Column(type: "timestamp with time zone", nullable: true), + MaxPlayers = table.Column(type: "integer", nullable: false), + RoundDurationSeconds = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Text = table.Column(type: "text", nullable: false), + CreatorId = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + IsActive = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + GameId = table.Column(type: "integer", nullable: false), + RoundNumber = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + StartedAt = table.Column(type: "timestamp with time zone", nullable: false), + EndedAt = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "integer", nullable: false), + GameId = table.Column(type: "integer", nullable: false), + Score = table.Column(type: "integer", nullable: false), + SuccessfulSlips = table.Column(type: "integer", nullable: false), + FailedSlips = table.Column(type: "integer", nullable: false), + JoinedAt = table.Column(type: "timestamp with time zone", nullable: false), + IsReady = table.Column(type: "boolean", nullable: false), + ConnectionId = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PlayerId = table.Column(type: "integer", nullable: false), + PhraseId = table.Column(type: "integer", nullable: false), + GameRoundId = table.Column(type: "integer", nullable: false), + IsUsed = table.Column(type: "boolean", nullable: false), + AssignedAt = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + GameRoundId = table.Column(type: "integer", nullable: false), + ChallengingPlayerId = table.Column(type: "integer", nullable: false), + TargetPlayerId = table.Column(type: "integer", nullable: false), + TargetCardId = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ResolvedAt = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/SlipItIn.Server/Migrations/SlipItInDbContextModelSnapshot.cs b/SlipItIn.Server/Migrations/SlipItInDbContextModelSnapshot.cs new file mode 100644 index 0000000..e5d7409 --- /dev/null +++ b/SlipItIn.Server/Migrations/SlipItInDbContextModelSnapshot.cs @@ -0,0 +1,429 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HostId") + .HasColumnType("integer"); + + b.Property("LobbyCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("RoundDurationSeconds") + .HasColumnType("integer"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("RoundNumber") + .HasColumnType("integer"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("GameRounds"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.Phrase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("Phrases"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConnectionId") + .HasColumnType("text"); + + b.Property("FailedSlips") + .HasColumnType("integer"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("IsReady") + .HasColumnType("boolean"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("SuccessfulSlips") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameRoundId") + .HasColumnType("integer"); + + b.Property("IsUsed") + .HasColumnType("boolean"); + + b.Property("PhraseId") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChallengingPlayerId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameRoundId") + .HasColumnType("integer"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TargetCardId") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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 + } + } +} diff --git a/SlipItIn.Server/Program.cs b/SlipItIn.Server/Program.cs new file mode 100644 index 0000000..70ded0c --- /dev/null +++ b/SlipItIn.Server/Program.cs @@ -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(options => options.UseNpgsql()); + +// Services registrieren +builder.Services.AddTransient(); + +// 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(); + 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("/hubs/game"); + +app.Run(); diff --git a/SlipItIn.Server/Properties/launchSettings.json b/SlipItIn.Server/Properties/launchSettings.json new file mode 100644 index 0000000..70d8754 --- /dev/null +++ b/SlipItIn.Server/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/SlipItIn.Server/Services/GameService.cs b/SlipItIn.Server/Services/GameService.cs new file mode 100644 index 0000000..5e9c1a5 --- /dev/null +++ b/SlipItIn.Server/Services/GameService.cs @@ -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 _contextFactory; + private readonly ILogger _logger; + + public GameService(IDbContextFactory contextFactory, ILogger logger) + { + _contextFactory = contextFactory; + _logger = logger; + } + + public async Task 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 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 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 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> 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(); + + 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 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 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 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 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 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 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; + } +} diff --git a/SlipItIn.Server/Services/IGameService.cs b/SlipItIn.Server/Services/IGameService.cs new file mode 100644 index 0000000..988c5ce --- /dev/null +++ b/SlipItIn.Server/Services/IGameService.cs @@ -0,0 +1,19 @@ +using SlipItIn.Shared.DTOs; +using SlipItIn.Shared.Models; + +namespace SlipItIn.Server.Services; + +public interface IGameService +{ + Task CreateGameAsync(int hostUserId, string? connectionId = null); + Task JoinGameAsync(string lobbyCode, int userId, string? connectionId = null); + Task StartGameAsync(int gameId); + Task SetPlayerReadyAsync(int gameId, int playerId); + Task> DealCardsAsync(int gameId); + Task SubmitSlipAsync(int gameId, int playerId, int cardId); + Task CreateChallengeAsync(int gameId, int challengingPlayerId, int targetCardId); + Task ResolveChallengeAsync(int challengeId, bool approved); + Task GetGameStateAsync(int gameId); + Task GetPlayerHandAsync(int playerId); + Task GetGameAsync(int gameId); +} diff --git a/SlipItIn.Server/SlipItIn.Server.csproj b/SlipItIn.Server/SlipItIn.Server.csproj new file mode 100644 index 0000000..a381114 --- /dev/null +++ b/SlipItIn.Server/SlipItIn.Server.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/SlipItIn.Server/SlipItIn.Server.http b/SlipItIn.Server/SlipItIn.Server.http new file mode 100644 index 0000000..c902001 --- /dev/null +++ b/SlipItIn.Server/SlipItIn.Server.http @@ -0,0 +1,6 @@ +@SlipItIn.Server_HostAddress = http://localhost:5234 + +GET {{SlipItIn.Server_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/SlipItIn.Server/appsettings.Development.json b/SlipItIn.Server/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/SlipItIn.Server/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/SlipItIn.Server/appsettings.json b/SlipItIn.Server/appsettings.json new file mode 100644 index 0000000..48d5f33 --- /dev/null +++ b/SlipItIn.Server/appsettings.json @@ -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 + } +} diff --git a/SlipItIn.Shared/DTOs/GameStateDto.cs b/SlipItIn.Shared/DTOs/GameStateDto.cs new file mode 100644 index 0000000..8bbccbb --- /dev/null +++ b/SlipItIn.Shared/DTOs/GameStateDto.cs @@ -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 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 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; } +} diff --git a/SlipItIn.Shared/Models/Game.cs b/SlipItIn.Shared/Models/Game.cs new file mode 100644 index 0000000..f215c69 --- /dev/null +++ b/SlipItIn.Shared/Models/Game.cs @@ -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 Players { get; set; } = []; + public ICollection Rounds { get; set; } = []; +} diff --git a/SlipItIn.Shared/Models/GameRound.cs b/SlipItIn.Shared/Models/GameRound.cs new file mode 100644 index 0000000..b65b74b --- /dev/null +++ b/SlipItIn.Shared/Models/GameRound.cs @@ -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 ActiveCards { get; set; } = []; + public ICollection Challenges { get; set; } = []; +} diff --git a/SlipItIn.Shared/Models/Phrase.cs b/SlipItIn.Shared/Models/Phrase.cs new file mode 100644 index 0000000..1752013 --- /dev/null +++ b/SlipItIn.Shared/Models/Phrase.cs @@ -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 PlayerCards { get; set; } = []; +} diff --git a/SlipItIn.Shared/Models/Player.cs b/SlipItIn.Shared/Models/Player.cs new file mode 100644 index 0000000..6560c2c --- /dev/null +++ b/SlipItIn.Shared/Models/Player.cs @@ -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 Cards { get; set; } = []; +} diff --git a/SlipItIn.Shared/Models/PlayerCard.cs b/SlipItIn.Shared/Models/PlayerCard.cs new file mode 100644 index 0000000..c1846c6 --- /dev/null +++ b/SlipItIn.Shared/Models/PlayerCard.cs @@ -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!; +} diff --git a/SlipItIn.Shared/Models/SlipChallenge.cs b/SlipItIn.Shared/Models/SlipChallenge.cs new file mode 100644 index 0000000..6a92e97 --- /dev/null +++ b/SlipItIn.Shared/Models/SlipChallenge.cs @@ -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 +} diff --git a/SlipItIn.Shared/Models/User.cs b/SlipItIn.Shared/Models/User.cs new file mode 100644 index 0000000..f752259 --- /dev/null +++ b/SlipItIn.Shared/Models/User.cs @@ -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 Players { get; set; } = []; +} diff --git a/SlipItIn.Shared/SlipItIn.Shared.csproj b/SlipItIn.Shared/SlipItIn.Shared.csproj new file mode 100644 index 0000000..22ef70d --- /dev/null +++ b/SlipItIn.Shared/SlipItIn.Shared.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/SlipItIn.slnx b/SlipItIn.slnx index 9e45a46..0972ead 100644 --- a/SlipItIn.slnx +++ b/SlipItIn.slnx @@ -1,6 +1,12 @@ + + + + + + diff --git a/SlipItIn/SlipItIn.csproj b/SlipItIn/SlipItIn.csproj index 9ff30d5..249c943 100644 --- a/SlipItIn/SlipItIn.csproj +++ b/SlipItIn/SlipItIn.csproj @@ -47,6 +47,38 @@ 10.0.17763.0 + + SlipItIn + + + + SlipItIn + + + + SlipItIn + + + + SlipItIn + + + + SlipItIn + + + + SlipItIn + + + + SlipItIn + + + + SlipItIn + + @@ -66,8 +98,8 @@ - - + +