Slip- und Beschuldigungslogik überarbeitet
All checks were successful
Build / build (push) Successful in 2m19s

Slips werden jetzt 30 Sekunden vorgehalten und anschließend automatisch abgeschlossen; direkte Spieler-Beschuldigungen, Win-Status, SignalR-Events, MAUI-UI, EF-Mapping/Migrationen sowie Tests wurden an die neue Spiellogik angepasst.
This commit is contained in:
Tim Krampitz
2026-10-04 19:14:16 +02:00
parent f54b177441
commit 010e844976
18 changed files with 1272 additions and 166 deletions

View File

@@ -33,6 +33,12 @@ public class SlipItInDbContext : DbContext
.WithMany()
.HasForeignKey(g => g.HostId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Game>()
.HasOne(g => g.WinnerPlayer)
.WithMany()
.HasForeignKey(g => g.WinnerPlayerId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Restrict);
// Player
modelBuilder.Entity<Player>()
@@ -92,6 +98,7 @@ public class SlipItInDbContext : DbContext
.HasOne(sc => sc.TargetCard)
.WithMany()
.HasForeignKey(sc => sc.TargetCardId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace SlipItIn.Server.Data;
public class SlipItInDbContextFactory : IDesignTimeDbContextFactory<SlipItInDbContext>
{
public SlipItInDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<SlipItInDbContext>();
optionsBuilder.UseNpgsql("Host=localhost;Database=slipitindb;Username=postgres;Password=postgres");
return new SlipItInDbContext(optionsBuilder.Options);
}
}

View File

@@ -242,6 +242,35 @@ public class GameHub : Hub
Success = result
});
await Clients.Caller.SendAsync("PlayerHandUpdated", hand);
// Nach 30 Sekunden Vorhaltezeit automatisch vollenden (falls keine Beschuldigung dazwischen kam)
_ = Task.Run(async () =>
{
try
{
// Kleiner Puffer, da Task.Delay minimal früher zurückkehren kann als die 30s-Prüfung
await Task.Delay(TimeSpan.FromSeconds(30.5));
var completed = await _gameService.CompleteSlipAsync(gameId, cardId);
if (completed)
{
var updatedHand = await _gameService.GetPlayerHandAsync(playerId);
var updatedGameState = await _gameService.GetGameStateAsync(gameId);
using var db = _contextFactory.CreateDbContext();
var targetP = await db.Players.FindAsync(playerId);
if (targetP?.ConnectionId != null)
{
await Clients.Client(targetP.ConnectionId).SendAsync("PlayerHandUpdated", updatedHand);
}
await Clients.Group(game.LobbyCode).SendAsync("GameStateUpdated", updatedGameState);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error completing slip for card {CardId}", cardId);
}
});
}
catch (UnauthorizedAccessException ex)
{
@@ -254,8 +283,59 @@ public class GameHub : Hub
}
}
public async Task ChallengePlayer(int gameId, int challengingPlayerId, int targetPlayerId)
{
try
{
var userId = GetAuthenticatedUserId();
await ValidatePlayerAccessAsync(challengingPlayerId, userId);
var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetPlayerId);
var game = await _gameService.GetGameAsync(gameId);
using var context = _contextFactory.CreateDbContext();
var challengingPlayer = await context.Players
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == challengingPlayerId);
var targetPlayer = await context.Players
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == targetPlayerId);
var challengeDto = new SlipChallengeDto
{
ChallengeId = challenge.Id,
ChallengingPlayerId = challengingPlayerId,
ChallengingPlayerName = challengingPlayer?.User.Username ?? string.Empty,
TargetPlayerId = challenge.TargetPlayerId,
TargetPlayerName = targetPlayer?.User.Username ?? string.Empty,
TargetCardId = challenge.TargetCardId,
Status = challenge.Status.ToString(),
CreatedAt = challenge.CreatedAt
};
await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto);
// Sende direkte Notification an den beschuldigten Spieler
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 player");
await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while challenging player." });
}
}
public async Task ChallengeSlip(int gameId, int challengingPlayerId, int targetCardId)
{
// Kompatibilitätsmethode
try
{
var userId = GetAuthenticatedUserId();
@@ -269,13 +349,18 @@ public class GameHub : Hub
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == challengingPlayerId);
var targetPlayer = await context.Players
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == challenge.TargetPlayerId);
var challengeDto = new SlipChallengeDto
{
ChallengeId = challenge.Id,
ChallengingPlayerId = challengingPlayerId,
ChallengingPlayerName = challengingPlayer?.User.Username ?? string.Empty,
TargetPlayerId = challenge.TargetPlayerId,
TargetCardId = targetCardId,
TargetPlayerName = targetPlayer?.User.Username ?? string.Empty,
TargetCardId = challenge.TargetCardId,
Status = challenge.Status.ToString(),
CreatedAt = challenge.CreatedAt
};
@@ -283,7 +368,6 @@ public class GameHub : Hub
await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto);
// Sende direkte Notification an den beschuldigten Spieler
var targetPlayer = await context.Players.FindAsync(challenge.TargetPlayerId);
if (targetPlayer?.ConnectionId != null)
{
await Clients.Client(targetPlayer.ConnectionId).SendAsync("ChallengeReceived", challengeDto);
@@ -300,15 +384,22 @@ public class GameHub : Hub
}
}
public async Task ResolveChallenge(int challengeId, bool approved)
public async Task ResolveChallenge(int challengeId, bool approved, int? discardCardId = null)
{
try
{
var userId = GetAuthenticatedUserId();
await ValidateChallengeTargetAccessAsync(challengeId, userId);
var result = await _gameService.ResolveChallengeAsync(challengeId, approved);
var game = await _gameService.GetGameAsync(result.GameRound.GameId);
var result = await _gameService.ResolveChallengeAsync(challengeId, approved, discardCardId);
int gameId = result.GameRound?.GameId ?? 0;
if (gameId == 0)
{
using var context = _contextFactory.CreateDbContext();
var round = await context.GameRounds.FindAsync(result.GameRoundId);
gameId = round?.GameId ?? 0;
}
var game = await _gameService.GetGameAsync(gameId);
await Clients.Group(game.LobbyCode).SendAsync("ChallengeResolved", new
{
@@ -319,16 +410,19 @@ public class GameHub : Hub
// Aktualisierte Handkarten an betroffene Spieler senden
var challengingHand = await _gameService.GetPlayerHandAsync(result.ChallengingPlayerId);
var targetHand = await _gameService.GetPlayerHandAsync(result.TargetPlayerId);
var gameState = await _gameService.GetGameStateAsync(gameId);
using var context = _contextFactory.CreateDbContext();
var challengingPlayer = await context.Players.FindAsync(result.ChallengingPlayerId);
var targetPlayer = await context.Players.FindAsync(result.TargetPlayerId);
using var db = _contextFactory.CreateDbContext();
var challengingPlayer = await db.Players.FindAsync(result.ChallengingPlayerId);
var targetPlayer = await db.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);
await Clients.Group(game.LobbyCode).SendAsync("GameStateUpdated", gameState);
}
catch (UnauthorizedAccessException ex)
{
@@ -354,6 +448,17 @@ public class GameHub : Hub
var gameState = await _gameService.GetGameStateAsync(gameId);
await Clients.Caller.SendAsync("GameStateUpdated", gameState);
// Abgelaufene Slips wurden beim Laden des Zustands abgeschlossen; Hand des Anfragenden mitsenden
using var context = _contextFactory.CreateDbContext();
var playerId = await context.Players
.Where(p => p.GameId == gameId && p.UserId == userId)
.Select(p => p.Id)
.FirstOrDefaultAsync();
if (playerId > 0)
{
await Clients.Caller.SendAsync("PlayerHandUpdated", await _gameService.GetPlayerHandAsync(playerId));
}
}
catch (UnauthorizedAccessException ex)
{
@@ -389,43 +494,11 @@ public class GameHub : Hub
.Select(g => g.LobbyCode)
.FirstAsync();
await Groups.AddToGroupAsync(Context.ConnectionId, lobbyCode);
// Der Client bekommt nach einem Reconnect keinen vollständigen Zustand
// mehr aus der Queue gespielt — er muss RequestGameState aufrufen.
}
}
catch (UnauthorizedAccessException)
{
// Nicht authentifiziert — ConnectionId wird nicht gespeichert.
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to register connection for authenticated user");
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("Client {ConnectionId} disconnected", Context.ConnectionId);
// ConnectionId zurücksetzen, damit keine Nachrichten an eine tote Verbindung gehen.
try
{
using var context = _contextFactory.CreateDbContext();
var players = await context.Players
.Where(p => p.ConnectionId == Context.ConnectionId)
.ToListAsync();
foreach (var player in players)
player.ConnectionId = null;
await context.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to clear connection id on disconnect");
}
await base.OnDisconnectedAsync(exception);
}
}

View File

@@ -0,0 +1,446 @@
// <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("20261004162039_UpdateGameRules")]
partial class UpdateGameRules
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.12")
.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.Property<int?>("WinnerPlayerId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("HostId");
b.HasIndex("LobbyCode")
.IsUnique();
b.HasIndex("WinnerPlayerId");
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.Property<DateTime?>("SlippedAt")
.HasColumnType("timestamp with time zone");
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.HasOne("SlipItIn.Shared.Models.Player", "WinnerPlayer")
.WithMany()
.HasForeignKey("WinnerPlayerId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Host");
b.Navigation("WinnerPlayer");
});
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);
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,78 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SlipItIn.Server.Migrations
{
/// <inheritdoc />
public partial class UpdateGameRules : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "TargetCardId",
table: "SlipChallenges",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AddColumn<DateTime>(
name: "SlippedAt",
table: "PlayerCards",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WinnerPlayerId",
table: "Games",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Games_WinnerPlayerId",
table: "Games",
column: "WinnerPlayerId");
migrationBuilder.AddForeignKey(
name: "FK_Games_Players_WinnerPlayerId",
table: "Games",
column: "WinnerPlayerId",
principalTable: "Players",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Games_Players_WinnerPlayerId",
table: "Games");
migrationBuilder.DropIndex(
name: "IX_Games_WinnerPlayerId",
table: "Games");
migrationBuilder.DropColumn(
name: "SlippedAt",
table: "PlayerCards");
migrationBuilder.DropColumn(
name: "WinnerPlayerId",
table: "Games");
migrationBuilder.AlterColumn<int>(
name: "TargetCardId",
table: "SlipChallenges",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
}
}
}

View File

@@ -17,7 +17,7 @@ namespace SlipItIn.Server.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("ProductVersion", "10.0.12")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -55,6 +55,9 @@ namespace SlipItIn.Server.Migrations
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int?>("WinnerPlayerId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("HostId");
@@ -62,6 +65,8 @@ namespace SlipItIn.Server.Migrations
b.HasIndex("LobbyCode")
.IsUnique();
b.HasIndex("WinnerPlayerId");
b.ToTable("Games");
});
@@ -188,6 +193,9 @@ namespace SlipItIn.Server.Migrations
b.Property<int>("PlayerId")
.HasColumnType("integer");
b.Property<DateTime?>("SlippedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("GameRoundId");
@@ -222,7 +230,7 @@ namespace SlipItIn.Server.Migrations
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("TargetCardId")
b.Property<int?>("TargetCardId")
.HasColumnType("integer");
b.Property<int>("TargetPlayerId")
@@ -289,7 +297,14 @@ namespace SlipItIn.Server.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SlipItIn.Shared.Models.Player", "WinnerPlayer")
.WithMany()
.HasForeignKey("WinnerPlayerId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Host");
b.Navigation("WinnerPlayer");
});
modelBuilder.Entity("SlipItIn.Shared.Models.GameRound", b =>
@@ -377,8 +392,7 @@ namespace SlipItIn.Server.Migrations
b.HasOne("SlipItIn.Shared.Models.PlayerCard", "TargetCard")
.WithMany()
.HasForeignKey("TargetCardId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("SlipItIn.Shared.Models.Player", "TargetPlayer")
.WithMany()

View File

@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SlipItIn.Server.Data;
using SlipItIn.Shared.DTOs;
using SlipItIn.Shared.Models;
@@ -30,18 +30,11 @@ public class GameService : IGameService
Status = GameStatus.Lobby
};
// Host wird automatisch als erster Spieler hinzugefügt
var hostPlayer = new Player { UserId = host.Id, ConnectionId = connectionId };
// Spieler direkt zur Liste des Spiels hinzufügen
game.Players.Add(hostPlayer);
// Es reicht, nur das Game hinzuzufügen — EF Core fügt den Player automatisch mit hinzu
context.Games.Add(game);
await context.SaveChangesAsync();
return game;
}
@@ -56,7 +49,6 @@ public class GameService : IGameService
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");
@@ -165,13 +157,69 @@ public class GameService : IGameService
if (card.GameRound.Status != RoundStatus.Active) throw new InvalidOperationException("Round is not active");
if (card.IsUsed) throw new InvalidOperationException("Card has already been used");
card.IsUsed = true;
// Karte wird für 30 Sekunden vorgehalten
card.SlippedAt = DateTime.UtcNow;
await context.SaveChangesAsync();
return true;
}
public async Task<SlipChallenge> CreateChallengeAsync(int gameId, int challengingPlayerId, int targetCardId)
public async Task<bool> CompleteSlipAsync(int gameId, int cardId, bool force = false)
{
using var context = _contextFactory.CreateDbContext();
var card = await context.PlayerCards
.Include(pc => pc.GameRound)
.ThenInclude(gr => gr.Game)
.Include(pc => pc.Player)
.FirstOrDefaultAsync(pc => pc.Id == cardId);
if (card == null || card.IsUsed || !card.SlippedAt.HasValue)
return false;
if (card.GameRound.GameId != gameId || card.GameRound.Status != RoundStatus.Active)
return false;
if (!force)
{
var elapsed = (DateTime.UtcNow - card.SlippedAt.Value).TotalSeconds;
if (elapsed < 30)
return false;
}
// Falls eine Beschuldigung gegen diesen Spieler aussteht, kann der Slip nicht abgeschlossen werden
var hasPendingChallenge = await context.SlipChallenges
.AnyAsync(c => c.GameRoundId == card.GameRoundId &&
c.TargetPlayerId == card.PlayerId &&
c.Status == ChallengeStatus.Pending);
if (hasPendingChallenge)
return false;
card.IsUsed = true;
card.SlippedAt = null;
card.Player.SuccessfulSlips += 1;
card.Player.Score += 1;
// Prüfung: Wenn der Spieler keine ungespielten Karten mehr hat, hat er gewonnen!
var remainingCards = await context.PlayerCards
.CountAsync(c => c.PlayerId == card.PlayerId && c.GameRoundId == card.GameRoundId && !c.IsUsed && c.Id != card.Id);
if (remainingCards == 0)
{
var game = card.GameRound.Game;
game.Status = GameStatus.Completed;
game.EndedAt = DateTime.UtcNow;
game.WinnerPlayerId = card.PlayerId;
card.GameRound.Status = RoundStatus.Completed;
card.GameRound.EndedAt = DateTime.UtcNow;
}
await context.SaveChangesAsync();
return true;
}
public async Task<SlipChallenge> CreateChallengeAsync(int gameId, int challengingPlayerId, int targetPlayerId)
{
using var context = _contextFactory.CreateDbContext();
@@ -184,27 +232,32 @@ public class GameService : IGameService
if (!challengerBelongsToGame)
throw new UnauthorizedAccessException("Challenger does not belong to this game");
var targetCard = await context.PlayerCards
.FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found");
var targetPlayer = await context.Players
.FirstOrDefaultAsync(p => p.Id == targetPlayerId && p.GameId == gameId)
?? throw new InvalidOperationException("Target player not found in this game");
if (targetCard.GameRoundId != currentRound.Id)
throw new InvalidOperationException("Card does not belong to the active round");
if (targetCard.PlayerId == challengingPlayerId)
throw new InvalidOperationException("Cannot challenge your own card");
if (!targetCard.IsUsed)
throw new InvalidOperationException("Card has not been played yet");
if (targetPlayerId == challengingPlayerId)
throw new InvalidOperationException("Cannot challenge yourself");
// Eventuell aktuell vorgehaltene Karte finden
var slippingCard = await context.PlayerCards
.Where(pc => pc.PlayerId == targetPlayerId && pc.GameRoundId == currentRound.Id && pc.SlippedAt.HasValue && !pc.IsUsed)
.OrderByDescending(pc => pc.SlippedAt)
.FirstOrDefaultAsync();
var pendingExists = await context.SlipChallenges
.AnyAsync(c => c.TargetCardId == targetCardId && c.Status == ChallengeStatus.Pending);
.AnyAsync(c => c.GameRoundId == currentRound.Id &&
c.TargetPlayerId == targetPlayerId &&
c.Status == ChallengeStatus.Pending);
if (pendingExists)
throw new InvalidOperationException("A challenge for this card is already pending");
throw new InvalidOperationException("A challenge for this player is already pending");
var challenge = new SlipChallenge
{
GameRoundId = currentRound.Id,
ChallengingPlayerId = challengingPlayerId,
TargetPlayerId = targetCard.PlayerId,
TargetCardId = targetCardId,
TargetPlayerId = targetPlayerId,
TargetCardId = slippingCard?.Id,
Status = ChallengeStatus.Pending
};
@@ -214,36 +267,88 @@ public class GameService : IGameService
return challenge;
}
public async Task<SlipChallenge> ResolveChallengeAsync(int challengeId, bool approved)
public async Task<SlipChallenge> ResolveChallengeAsync(int challengeId, bool approved, int? discardCardId = null)
{
using var context = _contextFactory.CreateDbContext();
var challenge = await context.SlipChallenges
.Include(sc => sc.GameRound)
.Include(sc => sc.TargetCard)
.ThenInclude(gr => gr.Game)
.Include(sc => sc.ChallengingPlayer)
.Include(sc => sc.TargetPlayer)
.ThenInclude(p => p.Cards)
.FirstOrDefaultAsync(sc => sc.Id == challengeId) ?? throw new InvalidOperationException("Challenge not found");
if (challenge.Status != ChallengeStatus.Pending)
throw new InvalidOperationException("Challenge has already been resolved");
// approved = Beschuldigung stimmt (Phrase war ein unberechtigtes SlipIn des Beschuldigten)
// rejected = Beschuldigung stimmt nicht (Phrase wurde regelkonform gespielt)
if (approved)
{
// Berechtigte Beschuldigung: Beschuldigter muss die Phrase an den Beschuldiger abgeben
challenge.TargetCard.PlayerId = challenge.ChallengingPlayerId;
// Beschuldigung angenommen:
// 1. Beschuldiger erhält Punkt
challenge.ChallengingPlayer.Score += 1;
challenge.TargetPlayer.FailedSlips += 1;
// 2. Vorgehaltene Karte zählt nicht!
var slippingCards = challenge.TargetPlayer.Cards
.Where(c => c.GameRoundId == challenge.GameRoundId && c.SlippedAt.HasValue && !c.IsUsed)
.ToList();
// 3. Eine Karte ablegen
PlayerCard? cardToDiscard = null;
if (discardCardId.HasValue)
{
cardToDiscard = challenge.TargetPlayer.Cards
.FirstOrDefault(c => c.Id == discardCardId.Value && !c.IsUsed);
}
cardToDiscard ??= slippingCards.FirstOrDefault()
?? challenge.TargetPlayer.Cards.FirstOrDefault(c => c.GameRoundId == challenge.GameRoundId && !c.IsUsed);
if (cardToDiscard != null)
{
cardToDiscard.IsUsed = true;
cardToDiscard.SlippedAt = null;
}
// Andere vorgehaltene Slips zurücksetzen ("zählt nicht")
foreach (var slipping in slippingCards.Where(c => c.Id != cardToDiscard?.Id))
{
slipping.SlippedAt = null;
}
// 4. Zwei neue Karten ziehen
var heldPhraseIds = challenge.TargetPlayer.Cards.Select(c => c.PhraseId).ToHashSet();
var availablePhrases = await context.Phrases
.Where(p => p.IsActive && !heldPhraseIds.Contains(p.Id))
.ToListAsync();
if (availablePhrases.Count < 2)
{
availablePhrases = await context.Phrases.Where(p => p.IsActive).ToListAsync();
}
var newPhrases = availablePhrases
.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue))
.Take(2)
.ToList();
foreach (var phrase in newPhrases)
{
var newCard = new PlayerCard
{
PlayerId = challenge.TargetPlayerId,
PhraseId = phrase.Id,
GameRoundId = challenge.GameRoundId,
IsUsed = false
};
context.PlayerCards.Add(newCard);
}
}
else
{
// Falsche Beschuldigung: Phrase bleibt beim Beschuldigten
challenge.TargetPlayer.SuccessfulSlips += 1;
challenge.TargetPlayer.Score += 1;
challenge.ChallengingPlayer.FailedSlips += 1;
// Anfechten: "dann wird die Beschuldigung zurückgenommen."
// Status wird auf Rejected gesetzt, keine Strafen.
}
challenge.Status = approved ? ChallengeStatus.Approved : ChallengeStatus.Rejected;
@@ -257,12 +362,16 @@ public class GameService : IGameService
{
using var context = _contextFactory.CreateDbContext();
await CompleteExpiredSlipsInternalAsync(context, gameId);
var game = await context.Games
.Include(g => g.Players)
.ThenInclude(p => p.User)
.ThenInclude(p => p.User)
.Include(g => g.Players)
.ThenInclude(p => p.Cards)
.ThenInclude(p => p.Cards)
.Include(g => g.Rounds)
.Include(g => g.WinnerPlayer)
.ThenInclude(wp => wp!.User)
.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
var playerInfos = game.Players.Select(p => new PlayerInfoDto
@@ -271,7 +380,7 @@ public class GameService : IGameService
Username = p.User.Username,
Score = p.Score,
IsReady = p.IsReady,
CardCount = p.Cards.Count(c => !c.IsUsed) // Nur Anzahl, nicht die Karten selbst!
CardCount = p.Cards.Count(c => !c.IsUsed)
}).ToList();
var currentRound = game.Rounds
@@ -287,6 +396,12 @@ public class GameService : IGameService
roundTimeRemaining = Math.Max(0, roundDurationSeconds - elapsedSeconds);
}
string? winnerUsername = game.WinnerPlayer?.User?.Username;
if (winnerUsername == null && game.WinnerPlayerId.HasValue)
{
winnerUsername = game.Players.FirstOrDefault(p => p.Id == game.WinnerPlayerId.Value)?.User?.Username;
}
return new GameStateDto
{
GameId = game.Id,
@@ -295,7 +410,9 @@ public class GameService : IGameService
Players = playerInfos,
HostUserId = game.HostId,
CurrentRound = currentRound?.RoundNumber ?? 0,
RoundTimeRemaining = roundTimeRemaining
RoundTimeRemaining = roundTimeRemaining,
WinnerPlayerId = game.WinnerPlayerId,
WinnerUsername = winnerUsername
};
}
@@ -303,8 +420,12 @@ public class GameService : IGameService
{
using var context = _contextFactory.CreateDbContext();
// Nur Karten der aktuell aktiven Runde zurückgeben — sonst wächst die Hand
// über Runden hinweg unbegrenzt und enthält veraltete Karten.
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player != null)
{
await CompleteExpiredSlipsInternalAsync(context, player.GameId);
}
var activeRoundIds = await context.GameRounds
.Where(gr => gr.Status == RoundStatus.Active &&
gr.Game.Players.Any(p => p.Id == playerId))
@@ -314,19 +435,32 @@ public class GameService : IGameService
var cards = await context.PlayerCards
.Include(pc => pc.Phrase)
.Where(pc => pc.PlayerId == playerId && activeRoundIds.Contains(pc.GameRoundId))
.Select(pc => new PlayerCardDto
.ToListAsync();
var cardDtos = cards.Select(pc =>
{
int remainingHold = 0;
if (pc.SlippedAt.HasValue && !pc.IsUsed)
{
var elapsed = (int)(DateTime.UtcNow - pc.SlippedAt.Value).TotalSeconds;
remainingHold = Math.Max(0, 30 - elapsed);
}
return new PlayerCardDto
{
CardId = pc.Id,
PhraseId = pc.PhraseId,
Text = pc.Phrase.Text,
IsUsed = pc.IsUsed
})
.ToListAsync();
IsUsed = pc.IsUsed,
SlippedAt = pc.SlippedAt,
SlippedHoldSecondsRemaining = remainingHold
};
}).ToList();
return new PlayerHandDto
{
PlayerId = playerId,
Cards = cards
Cards = cardDtos
};
}
@@ -337,6 +471,65 @@ public class GameService : IGameService
?? throw new InvalidOperationException("Game not found");
}
private static async Task CompleteExpiredSlipsInternalAsync(SlipItInDbContext context, int gameId)
{
var expiredThreshold = DateTime.UtcNow.AddSeconds(-30);
var candidateCards = await context.PlayerCards
.Include(pc => pc.GameRound)
.ThenInclude(gr => gr.Game)
.Include(pc => pc.Player)
.Where(pc => pc.GameRound.GameId == gameId &&
pc.GameRound.Status == RoundStatus.Active &&
!pc.IsUsed &&
pc.SlippedAt.HasValue &&
pc.SlippedAt.Value <= expiredThreshold)
.ToListAsync();
if (candidateCards.Count == 0)
return;
var pendingChallengeTargetIds = await context.SlipChallenges
.Where(sc => sc.GameRound.GameId == gameId && sc.Status == ChallengeStatus.Pending)
.Select(sc => sc.TargetPlayerId)
.Distinct()
.ToListAsync();
bool stateChanged = false;
foreach (var card in candidateCards)
{
if (pendingChallengeTargetIds.Contains(card.PlayerId))
continue;
card.IsUsed = true;
card.SlippedAt = null;
card.Player.SuccessfulSlips += 1;
card.Player.Score += 1;
stateChanged = true;
var remainingCount = await context.PlayerCards
.CountAsync(c => c.PlayerId == card.PlayerId && c.GameRoundId == card.GameRoundId && !c.IsUsed && c.Id != card.Id);
if (remainingCount == 0)
{
var game = card.GameRound.Game;
if (game.Status != GameStatus.Completed)
{
game.Status = GameStatus.Completed;
game.EndedAt = DateTime.UtcNow;
game.WinnerPlayerId = card.PlayerId;
card.GameRound.Status = RoundStatus.Completed;
card.GameRound.EndedAt = DateTime.UtcNow;
}
}
}
if (stateChanged)
{
await context.SaveChangesAsync();
}
}
private static string GenerateLobbyCode()
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

View File

@@ -11,8 +11,9 @@ public interface IGameService
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<bool> CompleteSlipAsync(int gameId, int cardId, bool force = false);
Task<SlipChallenge> CreateChallengeAsync(int gameId, int challengingPlayerId, int targetPlayerId);
Task<SlipChallenge> ResolveChallengeAsync(int challengeId, bool approved, int? discardCardId = null);
Task<GameStateDto> GetGameStateAsync(int gameId);
Task<PlayerHandDto> GetPlayerHandAsync(int playerId);
Task<Game> GetGameAsync(int gameId);