Merge branch 'feature/techdebt-cleanup' of https://git.krampitz.win/tim/SlipItIn into feature/techdebt-cleanup
Some checks failed
Build / build (pull_request) Failing after 3m29s
Some checks failed
Build / build (pull_request) Failing after 3m29s
Signed-off-by: Tim Krampitz <Tim.Krampitz@live.com>
This commit is contained in:
26
.github/workflows/build.yml
vendored
Normal file
26
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore SlipItIn.slnx
|
||||
|
||||
- name: Build
|
||||
run: dotnet build SlipItIn.slnx --no-restore --configuration Release
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -361,6 +361,8 @@ MigrationBackup/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
# Legacy: urspruenglich SQLite-Dateien — die Anwendung nutzt inzwischen PostgreSQL (via Aspire).
|
||||
# Eintraege bleiben als Sicherheitsnetz, falls lokal noch alte DB-Dateien existieren.
|
||||
/SlipItIn.Server/slipitIn.db
|
||||
/SlipItIn.Server/slipitIn.db-shm
|
||||
/SlipItIn.Server/slipitIn.db-wal
|
||||
|
||||
54
README.md
54
README.md
@@ -1 +1,53 @@
|
||||
# SlipItIn
|
||||
# SlipItIn
|
||||
|
||||
Ein Echtzeit-Multiplayer-Partyspiel: Jeder Spieler erhält Phrasen-Karten und versucht, sie unbemerkt in die laufende Runde zu "slippen". Wer einen fremden Slip erkennt, kann ihn anfechten — bei einer falschen Beschuldigung wandert die Phrase allerdings zum Beschuldiger.
|
||||
|
||||
## Tech-Stack
|
||||
|
||||
- **Client:** .NET MAUI (.NET 10) mit CommunityToolkit.Mvvm und SignalR-Client
|
||||
- **Server:** ASP.NET Core 10 Web API + SignalR, EF Core mit PostgreSQL
|
||||
- **Orchestrierung:** .NET Aspire (`SlipItIn.AppHost`) startet Server inklusive PostgreSQL-Container
|
||||
- **Shared:** Gemeinsame Modelle und DTOs in `SlipItIn.Shared`
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- .NET 10 SDK
|
||||
- Docker oder Podman (für den PostgreSQL-Container, den Aspire bereitstellt)
|
||||
- MAUI-Workload: `dotnet workload install maui`
|
||||
- Für den Server allein genügt die Aspire-Workload-Unterstützung des SDKs
|
||||
|
||||
## Ausführen
|
||||
|
||||
```powershell
|
||||
dotnet run --project SlipItIn.AppHost/SlipItIn.AppHost.csproj
|
||||
```
|
||||
|
||||
Der AppHost startet PostgreSQL (Container), wendet EF-Migrationen automatisch an und seedet Testdaten. Die MAUI-App verbindet sich standardmäßig mit `https://localhost:7274` (Android-Emulator: `http://10.0.2.2:5234`).
|
||||
|
||||
## Konfiguration
|
||||
|
||||
| Key | Zweck |
|
||||
| --- | --- |
|
||||
| `Jwt:Key` | Signaturschlüssel für JWT (mindestens 32 Zeichen). **Nicht committen** — via User-Secrets oder Umgebungsvariable setzen. In Development ist ein Fallback-Key hinterlegt; in Production bricht der Server ohne konfigurierten Key den Start ab. |
|
||||
| `Jwt:Issuer` / `Jwt:Audience` | Token-Aussteller bzw. -Empfänger (Defaults: `SlipItInServer` / `SlipItInClient`) |
|
||||
| `ConnectionStrings:postgresdb` | Wird lokal von Aspire injiziert; muss nicht manuell gesetzt werden |
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
- `SlipItIn/` — MAUI-Client (Views, ViewModels, Services, Offline-Queue)
|
||||
- `SlipItIn.Server/` — Web API, SignalR-Hub, EF Core, Migrations
|
||||
- `SlipItIn.Shared/` — Datenbank-Entitäten und SignalR-DTOs
|
||||
- `SlipItIn.AppHost/` — Aspire-Orchestrierung
|
||||
- `SlipItIn.ServiceDefaults/` — Geteilte Aspire-Service-Defaults
|
||||
- `openwiki/` — Generierte Dokumentation (wird automatisch aktualisiert, bitte nicht von Hand editieren)
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
dotnet build SlipItIn.slnx
|
||||
```
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Dokumentationssprache: Handgepflegte Dateien (diese README) sind Deutsch, die generierte OpenWiki-Dokumentation ist Englisch.
|
||||
- `.gitignore` enthält noch Einträge für `slipitIn.db` — ein Überbleibsel aus der ursprünglichen SQLite-Planung. Die Anwendung nutzt PostgreSQL.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<Solution>
|
||||
<Project Path="SlipItIN.AppHost/SlipItIn.AppHost.csproj" />
|
||||
<Project Path="SlipItIn.AppHost/SlipItIn.AppHost.csproj" />
|
||||
<Project Path="SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj" />
|
||||
<Project Path="SlipItIn.Server/SlipItIn.Server.csproj" />
|
||||
<Project Path="SlipItIN.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
|
||||
<Project Path="SlipItIn.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
|
||||
<Project Path="SlipItIn.Shared/SlipItIn.Shared.csproj" />
|
||||
<Project Path="SlipItIN/SlipItIn.csproj">
|
||||
<Project Path="SlipItIn/SlipItIn.csproj">
|
||||
<Deploy Solution="Debug|*" />
|
||||
</Project>
|
||||
</Solution>
|
||||
|
||||
@@ -17,11 +17,13 @@ public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IDbContextFactory<SlipItInDbContext> _contextFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IHostEnvironment _environment;
|
||||
|
||||
public AuthController(IDbContextFactory<SlipItInDbContext> contextFactory, IConfiguration configuration)
|
||||
public AuthController(IDbContextFactory<SlipItInDbContext> contextFactory, IConfiguration configuration, IHostEnvironment environment)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
_configuration = configuration;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
@@ -30,6 +32,9 @@ public class AuthController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return BadRequest("Username, Email und Passwort sind erforderlich.");
|
||||
|
||||
if (request.Password.Length < 8)
|
||||
return BadRequest("Das Passwort muss mindestens 8 Zeichen lang sein.");
|
||||
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var normalizedEmail = request.Email.Trim().ToLowerInvariant();
|
||||
@@ -86,7 +91,7 @@ public class AuthController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
UserId = int.Parse(userIdClaim),
|
||||
UserId = int.TryParse(userIdClaim, out var parsedUserId) ? parsedUserId : 0,
|
||||
Username = username,
|
||||
Email = email
|
||||
});
|
||||
@@ -94,7 +99,13 @@ public class AuthController : ControllerBase
|
||||
|
||||
private AuthResponseDto CreateAuthResponse(User user)
|
||||
{
|
||||
var jwtKey = _configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
|
||||
var jwtKey = _configuration["Jwt:Key"];
|
||||
if (string.IsNullOrWhiteSpace(jwtKey))
|
||||
{
|
||||
if (!_environment.IsDevelopment())
|
||||
throw new InvalidOperationException("Jwt:Key ist nicht konfiguriert.");
|
||||
jwtKey = "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef";
|
||||
}
|
||||
var jwtIssuer = _configuration["Jwt:Issuer"] ?? "SlipItInServer";
|
||||
var jwtAudience = _configuration["Jwt:Audience"] ?? "SlipItInClient";
|
||||
var expirationMinutes = int.TryParse(_configuration["Jwt:ExpirationMinutes"], out var parsedMinutes) ? parsedMinutes : 1440;
|
||||
|
||||
@@ -142,6 +142,7 @@ public class GameHub : Hub
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// Geprüfte Fachfehler mit festen, harmonsierten Texten dürfen an den Client.
|
||||
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -242,6 +243,11 @@ public class GameHub : Hub
|
||||
{
|
||||
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// Geprüfte Fachfehler (z. B. "Round is not active") dürfen an den Client.
|
||||
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error submitting slip");
|
||||
@@ -259,10 +265,16 @@ public class GameHub : Hub
|
||||
var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetCardId);
|
||||
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 challengeDto = new SlipChallengeDto
|
||||
{
|
||||
ChallengeId = challenge.Id,
|
||||
ChallengingPlayerId = challengingPlayerId,
|
||||
ChallengingPlayerName = challengingPlayer?.User.Username ?? string.Empty,
|
||||
TargetPlayerId = challenge.TargetPlayerId,
|
||||
TargetCardId = targetCardId,
|
||||
Status = challenge.Status.ToString(),
|
||||
@@ -272,7 +284,6 @@ public class GameHub : Hub
|
||||
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)
|
||||
{
|
||||
@@ -283,6 +294,11 @@ public class GameHub : Hub
|
||||
{
|
||||
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// Geprüfte Fachfehler (z. B. "Card does not belong to the active round") dürfen an den Client.
|
||||
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error challenging slip");
|
||||
@@ -372,9 +388,26 @@ public class GameHub : Hub
|
||||
{
|
||||
player.ConnectionId = Context.ConnectionId;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// (Re-)Join der Lobby-Gruppe, damit Group-Broadcasts nach einem Reconnect wieder ankommen.
|
||||
var lobbyCode = await context.Games
|
||||
.Where(g => g.Id == player.GameId)
|
||||
.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 { /* Ignorieren falls nicht authentifiziert */ }
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Nicht authentifiziert — ConnectionId wird nicht gespeichert.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to register connection for authenticated user");
|
||||
}
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
@@ -382,6 +415,23 @@ public class GameHub : Hub
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,27 @@ builder.AddServiceDefaults();
|
||||
|
||||
builder.AddNpgsqlDataSource(connectionName: "postgresdb");
|
||||
|
||||
builder.Services.AddDbContextFactory<SlipItInDbContext>(options => options.UseNpgsql());
|
||||
builder.Services.AddDbContextFactory<SlipItInDbContext>((sp, options) =>
|
||||
options.UseNpgsql(sp.GetRequiredService<Npgsql.NpgsqlDataSource>()));
|
||||
|
||||
// Services registrieren
|
||||
builder.Services.AddTransient<IGameService, GameService>();
|
||||
|
||||
// JWT Authentication
|
||||
var jwtKey = builder.Configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
|
||||
var jwtKey = builder.Configuration["Jwt:Key"];
|
||||
if (string.IsNullOrWhiteSpace(jwtKey) || jwtKey.Length < 32)
|
||||
{
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
jwtKey = "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef";
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Jwt:Key ist nicht konfiguriert oder kürzer als 32 Zeichen. " +
|
||||
"In Production muss ein eigener Schlüssel via Konfiguration/Umgebungsvariable gesetzt werden.");
|
||||
}
|
||||
}
|
||||
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "SlipItInServer";
|
||||
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "SlipItInClient";
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using SlipItIn.Server.Data;
|
||||
using SlipItIn.Shared.DTOs;
|
||||
using SlipItIn.Shared.Models;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace SlipItIn.Server.Services;
|
||||
|
||||
@@ -21,7 +22,7 @@ public class GameService : IGameService
|
||||
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 lobbyCode = await GenerateUniqueLobbyCodeAsync(context);
|
||||
var game = new Game
|
||||
{
|
||||
LobbyCode = lobbyCode,
|
||||
@@ -70,7 +71,16 @@ public class GameService : IGameService
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
|
||||
var game = await context.Games
|
||||
.Include(g => g.Players)
|
||||
.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
|
||||
|
||||
if (game.Status != GameStatus.Lobby)
|
||||
throw new InvalidOperationException("Game has already started or ended");
|
||||
|
||||
if (game.Players.Count < 2)
|
||||
throw new InvalidOperationException("At least 2 players are required to start the game");
|
||||
|
||||
game.Status = GameStatus.InProgress;
|
||||
game.StartedAt = DateTime.UtcNow;
|
||||
|
||||
@@ -85,12 +95,16 @@ public class GameService : IGameService
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId) ?? throw new InvalidOperationException("Player not found");
|
||||
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
|
||||
if (game.Status != GameStatus.Lobby)
|
||||
throw new InvalidOperationException("Game is not in lobby status");
|
||||
|
||||
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId && p.GameId == gameId)
|
||||
?? throw new InvalidOperationException("Player not found in this game");
|
||||
player.IsReady = true;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return await context.Games.FirstOrDefaultAsync(g => g.Id == gameId)
|
||||
?? throw new InvalidOperationException("Game not found");
|
||||
return game;
|
||||
}
|
||||
|
||||
public async Task<List<PlayerCard>> DealCardsAsync(int gameId)
|
||||
@@ -108,7 +122,7 @@ public class GameService : IGameService
|
||||
|
||||
foreach (var player in players)
|
||||
{
|
||||
var selectedPhrases = allPhrases.OrderBy(_ => Random.Shared.Next()).Take(5);
|
||||
var selectedPhrases = allPhrases.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue)).Take(5);
|
||||
|
||||
foreach (var phrase in selectedPhrases)
|
||||
{
|
||||
@@ -125,6 +139,7 @@ public class GameService : IGameService
|
||||
}
|
||||
|
||||
currentRound.Status = RoundStatus.Active;
|
||||
currentRound.StartedAt = DateTime.UtcNow;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return dealtCards;
|
||||
@@ -134,10 +149,18 @@ public class GameService : IGameService
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
|
||||
if (game.Status != GameStatus.InProgress)
|
||||
throw new InvalidOperationException("Game is not in progress");
|
||||
|
||||
var card = await context.PlayerCards
|
||||
.Include(pc => pc.Player)
|
||||
.Include(pc => pc.GameRound)
|
||||
.FirstOrDefaultAsync(pc => pc.Id == cardId) ?? throw new InvalidOperationException("Card not found");
|
||||
|
||||
if (card.PlayerId != playerId) throw new UnauthorizedAccessException("Card does not belong to player");
|
||||
if (card.GameRound.GameId != gameId) throw new InvalidOperationException("Card does not belong to this game");
|
||||
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;
|
||||
await context.SaveChangesAsync();
|
||||
@@ -149,13 +172,24 @@ public class GameService : IGameService
|
||||
{
|
||||
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 targetCard = await context.PlayerCards
|
||||
.FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found");
|
||||
|
||||
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");
|
||||
|
||||
var pendingExists = await context.SlipChallenges
|
||||
.AnyAsync(c => c.TargetCardId == targetCardId && c.Status == ChallengeStatus.Pending);
|
||||
if (pendingExists)
|
||||
throw new InvalidOperationException("A challenge for this card is already pending");
|
||||
|
||||
var challenge = new SlipChallenge
|
||||
{
|
||||
@@ -179,19 +213,30 @@ public class GameService : IGameService
|
||||
var challenge = await context.SlipChallenges
|
||||
.Include(sc => sc.GameRound)
|
||||
.Include(sc => sc.TargetCard)
|
||||
.Include(sc => sc.ChallengingPlayer)
|
||||
.Include(sc => sc.TargetPlayer)
|
||||
.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 (challenge.Status != ChallengeStatus.Pending)
|
||||
throw new InvalidOperationException("Challenge has already been resolved");
|
||||
|
||||
if (!approved)
|
||||
// approved = Beschuldigung stimmt (Phrase war ein unberechtigtes SlipIn des Beschuldigten)
|
||||
// rejected = Beschuldigung stimmt nicht (Phrase wurde regelkonform gespielt)
|
||||
|
||||
if (approved)
|
||||
{
|
||||
// FALSCHE BESCHULDIGUNG: Beschuldiger erhält die Phrase des Beschuldigten
|
||||
var targetCard = challenge.TargetCard;
|
||||
targetCard.PlayerId = challenge.ChallengingPlayerId;
|
||||
context.PlayerCards.Update(targetCard);
|
||||
// Berechtigte Beschuldigung: Beschuldigter muss die Phrase an den Beschuldiger abgeben
|
||||
challenge.TargetCard.PlayerId = challenge.ChallengingPlayerId;
|
||||
challenge.ChallengingPlayer.Score += 1;
|
||||
challenge.TargetPlayer.FailedSlips += 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Falsche Beschuldigung: Phrase bleibt beim Beschuldigten
|
||||
challenge.TargetPlayer.SuccessfulSlips += 1;
|
||||
challenge.TargetPlayer.Score += 1;
|
||||
challenge.ChallengingPlayer.FailedSlips += 1;
|
||||
}
|
||||
// Wenn approved: Phrase bleibt bei Beschuldigtem (= Phrase war tatsächlich ein SlipIn)
|
||||
|
||||
challenge.Status = approved ? ChallengeStatus.Approved : ChallengeStatus.Rejected;
|
||||
challenge.ResolvedAt = DateTime.UtcNow;
|
||||
@@ -218,7 +263,7 @@ public class GameService : IGameService
|
||||
Username = p.User.Username,
|
||||
Score = p.Score,
|
||||
IsReady = p.IsReady,
|
||||
CardCount = p.Cards.Count // Nur Anzahl, nicht die Karten selbst!
|
||||
CardCount = p.Cards.Count(c => !c.IsUsed) // Nur Anzahl, nicht die Karten selbst!
|
||||
}).ToList();
|
||||
|
||||
var currentRound = game.Rounds
|
||||
@@ -249,9 +294,17 @@ 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 activeRoundIds = await context.GameRounds
|
||||
.Where(gr => gr.Status == RoundStatus.Active &&
|
||||
gr.Game.Players.Any(p => p.Id == playerId))
|
||||
.Select(gr => gr.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var cards = await context.PlayerCards
|
||||
.Include(pc => pc.Phrase)
|
||||
.Where(pc => pc.PlayerId == playerId)
|
||||
.Where(pc => pc.PlayerId == playerId && activeRoundIds.Contains(pc.GameRoundId))
|
||||
.Select(pc => new PlayerCardDto
|
||||
{
|
||||
CardId = pc.Id,
|
||||
@@ -275,11 +328,25 @@ public class GameService : IGameService
|
||||
?? throw new InvalidOperationException("Game not found");
|
||||
}
|
||||
|
||||
private string GenerateLobbyCode()
|
||||
private static string GenerateLobbyCode()
|
||||
{
|
||||
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
var code = new string([.. Enumerable.Range(0, 6).Select(_ => chars[Random.Shared.Next(chars.Length)])]);
|
||||
var buffer = new char[6];
|
||||
for (var i = 0; i < buffer.Length; i++)
|
||||
buffer[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
return code;
|
||||
private static async Task<string> GenerateUniqueLobbyCodeAsync(SlipItInDbContext context, int maxAttempts = 10)
|
||||
{
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++)
|
||||
{
|
||||
var code = GenerateLobbyCode();
|
||||
var exists = await context.Games.AnyAsync(g => g.LobbyCode == code);
|
||||
if (!exists)
|
||||
return code;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not generate a unique lobby code");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,7 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"postgresdb": "Server=(localdb)\\postgres;Database=postgresdb"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
|
||||
"Issuer": "SlipItInServer",
|
||||
"Audience": "SlipItInClient",
|
||||
"ExpirationMinutes": 1440
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<Solution>
|
||||
<Project Path="SlipItIN.AppHost/SlipItIn.AppHost.csproj" />
|
||||
<Project Path="SlipItIn.AppHost/SlipItIn.AppHost.csproj" />
|
||||
<Project Path="SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj" />
|
||||
<Project Path="SlipItIn.Server/SlipItIn.Server.csproj" />
|
||||
<Project Path="SlipItIN.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
|
||||
<Project Path="SlipItIn.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
|
||||
<Project Path="SlipItIn.Shared/SlipItIn.Shared.csproj" />
|
||||
<Project Path="SlipItIN/SlipItIn.csproj">
|
||||
<Project Path="SlipItIn/SlipItIn.csproj">
|
||||
<Deploy Solution="Debug|*" />
|
||||
</Project>
|
||||
</Solution>
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace SlipItIn;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private readonly SemaphoreSlim _startupLock = new(1, 1);
|
||||
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -21,15 +23,25 @@ public partial class App : Application
|
||||
|
||||
window.Resumed += async (_, _) =>
|
||||
{
|
||||
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
|
||||
await gameStateService.ResyncAsync();
|
||||
try
|
||||
{
|
||||
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
|
||||
await gameStateService.ResyncAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Resume darf nicht abstürzen; Sync-Status wurde bereits gemeldet.
|
||||
}
|
||||
};
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
private static async Task InitializeSafeAsync()
|
||||
private async Task InitializeSafeAsync()
|
||||
{
|
||||
if (!await _startupLock.WaitAsync(0))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>();
|
||||
@@ -43,8 +55,14 @@ public partial class App : Application
|
||||
if (string.IsNullOrWhiteSpace(authSession.AccessToken))
|
||||
return;
|
||||
|
||||
var isSessionValid = await apiService.ValidateSessionAsync();
|
||||
if (!isSessionValid)
|
||||
var sessionResult = await apiService.ValidateSessionAsync();
|
||||
if (sessionResult == SessionValidationResult.NetworkError)
|
||||
{
|
||||
// Netzwerkfehler darf die lokale Session nicht zerstören.
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionResult == SessionValidationResult.Invalid)
|
||||
{
|
||||
await authSession.ClearSessionAsync();
|
||||
await gameStateService.ClearLocalGameDataAsync();
|
||||
@@ -60,5 +78,9 @@ public partial class App : Application
|
||||
{
|
||||
// Start darf nicht abstürzen, wenn Session/Netzwerk fehlschlägt.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_startupLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ public class QueuedGameAction
|
||||
public string Method { get; set; } = string.Empty;
|
||||
public string ArgumentsJson { get; set; } = "[]";
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
public int RetryCount { get; set; }
|
||||
}
|
||||
|
||||
@@ -28,20 +28,26 @@ public class ApiService : IApiService
|
||||
public Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default)
|
||||
=> PostAuthAsync("api/auth/login", request, cancellationToken);
|
||||
|
||||
public async Task<bool> ValidateSessionAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<SessionValidationResult> ValidateSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_authSession.AccessToken))
|
||||
return false;
|
||||
return SessionValidationResult.Invalid;
|
||||
|
||||
try
|
||||
{
|
||||
AttachAuthHeader();
|
||||
var response = await _httpClient.GetAsync("api/auth/me", cancellationToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
return response.IsSuccessStatusCode
|
||||
? SessionValidationResult.Valid
|
||||
: SessionValidationResult.Invalid;
|
||||
}
|
||||
catch
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return false;
|
||||
return SessionValidationResult.NetworkError;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return SessionValidationResult.NetworkError;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,21 +28,21 @@ public class GameStateService : IGameStateService
|
||||
|
||||
_signalR.LobbyCreated += (_, value) => _messenger.Send(new LobbyCreatedMessage(value));
|
||||
|
||||
_signalR.GameStateUpdated += async (_, state) =>
|
||||
_signalR.GameStateUpdated += (_, state) => RunSafeBridge(async () =>
|
||||
{
|
||||
CurrentGameState = state;
|
||||
await _localStorage.SaveGameStateAsync(state);
|
||||
_messenger.Send(new GameStateChangedMessage(state));
|
||||
};
|
||||
});
|
||||
|
||||
_signalR.GameStarted += (_, gameId) => _messenger.Send(new GameStartedMessage(gameId));
|
||||
|
||||
_signalR.PlayerHandUpdated += async (_, hand) =>
|
||||
_signalR.PlayerHandUpdated += (_, hand) => RunSafeBridge(async () =>
|
||||
{
|
||||
CurrentPlayerHand = hand;
|
||||
await _localStorage.SavePlayerHandAsync(hand);
|
||||
_messenger.Send(new PlayerHandChangedMessage(hand));
|
||||
};
|
||||
});
|
||||
|
||||
_signalR.ChallengeReceived += (_, challenge) =>
|
||||
{
|
||||
@@ -51,21 +51,26 @@ public class GameStateService : IGameStateService
|
||||
};
|
||||
|
||||
_signalR.ErrorReceived += (_, error) => _messenger.Send(new ErrorOccurredMessage(error));
|
||||
_signalR.ConnectionStateChanged += async (_, connected) =>
|
||||
_signalR.ConnectionStateChanged += (_, connected) => RunSafeBridge(async () =>
|
||||
{
|
||||
_messenger.Send(new ConnectionStateChangedMessage(connected));
|
||||
if (!connected)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await ResyncAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_messenger.Send(new ErrorOccurredMessage($"Resync fehlgeschlagen: {ex.Message}"));
|
||||
}
|
||||
};
|
||||
await ResyncAsync();
|
||||
});
|
||||
}
|
||||
|
||||
private async void RunSafeBridge(Func<Task> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
await action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_messenger.Send(new ErrorOccurredMessage($"Hintergrundfehler: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
@@ -153,12 +158,16 @@ public class GameStateService : IGameStateService
|
||||
PublishSyncState(false, "Bereit", 0);
|
||||
}
|
||||
|
||||
private const int MaxQueueRetries = 3;
|
||||
|
||||
private async Task FlushQueuedActionsCoreAsync()
|
||||
{
|
||||
if (!_signalR.IsConnected || _queuedActions.Count == 0)
|
||||
return;
|
||||
|
||||
var snapshot = _queuedActions.ToList();
|
||||
var poisoned = new List<QueuedGameAction>();
|
||||
|
||||
foreach (var action in snapshot)
|
||||
{
|
||||
try
|
||||
@@ -169,11 +178,23 @@ public class GameStateService : IGameStateService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_messenger.Send(new ErrorOccurredMessage($"Queue-Aktion fehlgeschlagen ({action.Method}): {ex.Message}"));
|
||||
break;
|
||||
action.RetryCount++;
|
||||
if (action.RetryCount >= MaxQueueRetries)
|
||||
{
|
||||
poisoned.Add(action);
|
||||
_messenger.Send(new ErrorOccurredMessage($"Queue-Aktion verworfen ({action.Method}): {ex.Message}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
_messenger.Send(new ErrorOccurredMessage($"Queue-Aktion fehlgeschlagen ({action.Method}): {ex.Message}"));
|
||||
break; // Bei transienten Fehlern den Rest der Queue nicht blockieren
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var item in poisoned)
|
||||
_queuedActions.Remove(item);
|
||||
|
||||
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,17 @@ using SlipItIn.Shared.DTOs;
|
||||
|
||||
namespace SlipItIn.Services.Interfaces;
|
||||
|
||||
public enum SessionValidationResult
|
||||
{
|
||||
Valid,
|
||||
Invalid,
|
||||
NetworkError
|
||||
}
|
||||
|
||||
public interface IApiService
|
||||
{
|
||||
Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
|
||||
Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
|
||||
Task<bool> ValidateSessionAsync(CancellationToken cancellationToken = default);
|
||||
Task<SessionValidationResult> ValidateSessionAsync(CancellationToken cancellationToken = default);
|
||||
Task LogoutAsync();
|
||||
}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SlipItIn.Models;
|
||||
using SlipItIn.Services.Interfaces;
|
||||
using SlipItIn.Shared.DTOs;
|
||||
|
||||
namespace SlipItIn.Services;
|
||||
|
||||
public class SignalRService : ISignalRService
|
||||
public class SignalRService : ISignalRService, IAsyncDisposable
|
||||
{
|
||||
private readonly IAppConfigurationService _configuration;
|
||||
private readonly IAuthSessionService _authSession;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly SemaphoreSlim _connectLock = new(1, 1);
|
||||
private HubConnection? _hubConnection;
|
||||
|
||||
public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession)
|
||||
public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession, IServiceProvider serviceProvider)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_authSession = authSession;
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected;
|
||||
@@ -33,36 +37,76 @@ public class SignalRService : ISignalRService
|
||||
if (IsConnected)
|
||||
return true;
|
||||
|
||||
EnsureConnection(token);
|
||||
|
||||
if (_hubConnection is null)
|
||||
return false;
|
||||
|
||||
ConnectionStateChanged?.Invoke(this, false);
|
||||
|
||||
await _connectLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await _hubConnection.StartAsync(cancellationToken);
|
||||
ConnectionStateChanged?.Invoke(this, true);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (IsConnected)
|
||||
return true;
|
||||
|
||||
// Bestehende (möglicherweise tote) Verbindung vollständig entsorgen,
|
||||
// damit der AccessTokenProvider keinen veralteten Token-Closure verwendet.
|
||||
if (_hubConnection is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
|
||||
await _hubConnection.StopAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort; Dispose folgt trotzdem.
|
||||
}
|
||||
|
||||
await _hubConnection.DisposeAsync();
|
||||
_hubConnection = null;
|
||||
}
|
||||
|
||||
EnsureConnection(token);
|
||||
|
||||
if (_hubConnection is null)
|
||||
return false;
|
||||
|
||||
ConnectionStateChanged?.Invoke(this, false);
|
||||
ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}");
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
await _hubConnection.StartAsync(cancellationToken);
|
||||
ConnectionStateChanged?.Invoke(this, true);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(this, false);
|
||||
ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
if (_hubConnection is null)
|
||||
return;
|
||||
await _connectLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_hubConnection is null)
|
||||
return;
|
||||
|
||||
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
|
||||
await _hubConnection.StopAsync();
|
||||
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
|
||||
await _hubConnection.StopAsync();
|
||||
|
||||
ConnectionStateChanged?.Invoke(this, false);
|
||||
await _hubConnection.DisposeAsync();
|
||||
_hubConnection = null;
|
||||
|
||||
ConnectionStateChanged?.Invoke(this, false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task CreateLobbyAsync() => InvokeAsync("CreateLobby");
|
||||
@@ -87,7 +131,7 @@ public class SignalRService : ISignalRService
|
||||
public async Task SendQueuedActionAsync(QueuedGameAction action)
|
||||
{
|
||||
if (_hubConnection is null)
|
||||
return;
|
||||
throw new InvalidOperationException("Keine aktive SignalR-Verbindung.");
|
||||
|
||||
object?[] args;
|
||||
try
|
||||
@@ -95,11 +139,11 @@ public class SignalRService : ISignalRService
|
||||
var doc = JsonDocument.Parse(action.ArgumentsJson);
|
||||
args = doc.RootElement.ValueKind == JsonValueKind.Array
|
||||
? doc.RootElement.EnumerateArray().Select(ToObject).ToArray()
|
||||
: [];
|
||||
: throw new InvalidOperationException($"Ungültige Queue-Aktion: {action.ArgumentsJson}");
|
||||
}
|
||||
catch
|
||||
catch (JsonException ex)
|
||||
{
|
||||
args = [];
|
||||
throw new InvalidOperationException($"Queue-Aktion '{action.Method}' ist beschädigt.", ex);
|
||||
}
|
||||
|
||||
await InvokeAsync(action.Method, args);
|
||||
@@ -113,7 +157,8 @@ public class SignalRService : ISignalRService
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(_configuration.HubUrl, options =>
|
||||
{
|
||||
options.AccessTokenProvider = () => Task.FromResult<string?>(_authSession.AccessToken ?? token);
|
||||
// Token immer zur Laufzeit aus der Session lesen — nie den Startwert cachen.
|
||||
options.AccessTokenProvider = () => Task.FromResult(_authSession.AccessToken);
|
||||
})
|
||||
.WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)])
|
||||
.Build();
|
||||
@@ -155,6 +200,13 @@ public class SignalRService : ISignalRService
|
||||
_hubConnection.On<PlayerHandDto>("PlayerHandUpdated", dto => PlayerHandUpdated?.Invoke(this, dto));
|
||||
_hubConnection.On<SlipChallengeDto>("ChallengeReceived", dto => ChallengeReceived?.Invoke(this, dto));
|
||||
|
||||
// Server-Broadcasts nach Slip/Challenge/Resolve, damit die UI auch ohne
|
||||
// vollständiges GameState-Update reagieren kann.
|
||||
_hubConnection.On<JsonElement>("SlipSubmitted", _ => RequestCurrentGameStateSafe());
|
||||
_hubConnection.On<JsonElement>("SlipChallenged", _ => RequestCurrentGameStateSafe());
|
||||
_hubConnection.On<JsonElement>("ChallengeResolved", _ => RequestCurrentGameStateSafe());
|
||||
_hubConnection.On<JsonElement>("CardsDealt", _ => RequestCurrentGameStateSafe());
|
||||
|
||||
_hubConnection.On<JsonElement>("Error", payload =>
|
||||
{
|
||||
var message = payload.TryGetProperty("message", out var msgElement)
|
||||
@@ -164,6 +216,26 @@ public class SignalRService : ISignalRService
|
||||
});
|
||||
}
|
||||
|
||||
private void RequestCurrentGameStateSafe()
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Lazy über ServiceProvider auflösen, um eine zirkuläre Abhängigkeit
|
||||
// SignalRService ↔ GameStateService zu vermeiden.
|
||||
var gameStateService = _serviceProvider.GetService<IGameStateService>();
|
||||
var gameState = gameStateService?.CurrentGameState;
|
||||
if (gameState?.GameId > 0 && IsConnected)
|
||||
await RequestGameStateAsync(gameState.GameId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Hintergrund-Refresh darf nicht abstürzen.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task InvokeAsync(string method, params object?[] args)
|
||||
{
|
||||
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
|
||||
@@ -186,4 +258,10 @@ public class SignalRService : ISignalRService
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hubConnection is not null)
|
||||
await _hubConnection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,35 +48,11 @@
|
||||
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-android|AnyCPU'">
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-ios|AnyCPU'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-maccatalyst|AnyCPU'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-windows10.0.19041.0|AnyCPU'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-ios|AnyCPU'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-maccatalyst|AnyCPU'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-windows10.0.19041.0|AnyCPU'">
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Release'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -89,7 +65,6 @@
|
||||
|
||||
<!-- Images -->
|
||||
<MauiImage Include="Resources\Images\*" />
|
||||
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
|
||||
|
||||
<!-- Custom Fonts -->
|
||||
<MauiFont Include="Resources\Fonts\*" />
|
||||
@@ -110,9 +85,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<MauiXaml Update="MainPage.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</MauiXaml>
|
||||
<MauiXaml Update="Views\ChallengeNotificationOverlay.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</MauiXaml>
|
||||
|
||||
@@ -19,6 +19,8 @@ public partial class GameBoardViewModel : BaseViewModel,
|
||||
private readonly ISignalRService _signalR;
|
||||
private readonly IGameStateService _gameStateService;
|
||||
private readonly IAuthSessionService _authSession;
|
||||
private readonly IDispatcherTimer? _roundTimer;
|
||||
private DateTime _lastStateUtc = DateTime.UtcNow;
|
||||
|
||||
[ObservableProperty]
|
||||
private int gameId;
|
||||
@@ -58,6 +60,26 @@ public partial class GameBoardViewModel : BaseViewModel,
|
||||
|
||||
IsConnected = _signalR.IsConnected;
|
||||
IsActive = true;
|
||||
|
||||
if (Application.Current?.Dispatcher is { } dispatcher)
|
||||
{
|
||||
_roundTimer = dispatcher.CreateTimer();
|
||||
_roundTimer.Interval = TimeSpan.FromSeconds(1);
|
||||
_roundTimer.Tick += OnRoundTimerTick;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRoundTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (RoundTimeRemaining <= 0)
|
||||
return;
|
||||
|
||||
var elapsed = (int)(DateTime.UtcNow - _lastStateUtc).TotalSeconds;
|
||||
if (elapsed > 0)
|
||||
{
|
||||
RoundTimeRemaining = Math.Max(0, RoundTimeRemaining - elapsed);
|
||||
_lastStateUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -123,9 +145,22 @@ public partial class GameBoardViewModel : BaseViewModel,
|
||||
if (PendingChallenge is null)
|
||||
return;
|
||||
|
||||
await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, true);
|
||||
var challengeId = PendingChallenge.ChallengeId;
|
||||
PendingChallenge = null;
|
||||
IsChallengeVisible = false;
|
||||
|
||||
await RunSafeAsync(async () =>
|
||||
{
|
||||
if (_signalR.IsConnected)
|
||||
{
|
||||
await _signalR.ResolveChallengeAsync(challengeId, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _gameStateService.EnqueueActionAsync("ResolveChallenge", challengeId, true);
|
||||
StatusMessage = "Offline: Antwort wurde zwischengespeichert.";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -134,9 +169,22 @@ public partial class GameBoardViewModel : BaseViewModel,
|
||||
if (PendingChallenge is null)
|
||||
return;
|
||||
|
||||
await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, false);
|
||||
var challengeId = PendingChallenge.ChallengeId;
|
||||
PendingChallenge = null;
|
||||
IsChallengeVisible = false;
|
||||
|
||||
await RunSafeAsync(async () =>
|
||||
{
|
||||
if (_signalR.IsConnected)
|
||||
{
|
||||
await _signalR.ResolveChallengeAsync(challengeId, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _gameStateService.EnqueueActionAsync("ResolveChallenge", challengeId, false);
|
||||
StatusMessage = "Offline: Antwort wurde zwischengespeichert.";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Receive(GameStateChangedMessage message)
|
||||
@@ -146,6 +194,7 @@ public partial class GameBoardViewModel : BaseViewModel,
|
||||
GameId = message.Value.GameId;
|
||||
CurrentRound = message.Value.CurrentRound;
|
||||
RoundTimeRemaining = message.Value.RoundTimeRemaining;
|
||||
_lastStateUtc = DateTime.UtcNow;
|
||||
|
||||
var me = _authSession.CurrentUser?.Username;
|
||||
var others = message.Value.Players
|
||||
@@ -170,27 +219,36 @@ public partial class GameBoardViewModel : BaseViewModel,
|
||||
|
||||
public void Receive(ChallengeReceivedMessage message)
|
||||
{
|
||||
PendingChallenge = message.Value;
|
||||
IsChallengeVisible = true;
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
PendingChallenge = message.Value;
|
||||
IsChallengeVisible = true;
|
||||
});
|
||||
}
|
||||
|
||||
public void Receive(ErrorOccurredMessage message)
|
||||
{
|
||||
StatusMessage = message.Value;
|
||||
MainThread.BeginInvokeOnMainThread(() => StatusMessage = message.Value);
|
||||
}
|
||||
|
||||
public void Receive(SyncStateChangedMessage message)
|
||||
{
|
||||
IsSyncing = message.Value.IsSyncing;
|
||||
SyncStatusText = message.Value.StatusText;
|
||||
PendingSyncActions = message.Value.PendingActions;
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
IsSyncing = message.Value.IsSyncing;
|
||||
SyncStatusText = message.Value.StatusText;
|
||||
PendingSyncActions = message.Value.PendingActions;
|
||||
});
|
||||
}
|
||||
|
||||
public void Receive(ConnectionStateChangedMessage message)
|
||||
{
|
||||
IsConnected = message.Value;
|
||||
MainThread.BeginInvokeOnMainThread(() => IsConnected = message.Value);
|
||||
}
|
||||
|
||||
public void StartRoundTimer() => _roundTimer?.Start();
|
||||
public void StopRoundTimer() => _roundTimer?.Stop();
|
||||
|
||||
private int GetCurrentPlayerId()
|
||||
{
|
||||
var username = _authSession.CurrentUser?.Username;
|
||||
|
||||
@@ -55,6 +55,16 @@ public partial class LobbyViewModel : BaseViewModel,
|
||||
IsActive = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reaktiviert das ViewModel nach einem Logout (Singleton-Lebensdauer).
|
||||
/// Wird von LobbyPage.OnNavigatedTo aufgerufen.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
IsConnected = _signalR.IsConnected;
|
||||
IsActive = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreateLobbyAsync()
|
||||
{
|
||||
@@ -105,6 +115,7 @@ public partial class LobbyViewModel : BaseViewModel,
|
||||
{
|
||||
await RunSafeAsync(async () =>
|
||||
{
|
||||
IsActive = false;
|
||||
await _signalR.DisconnectAsync();
|
||||
await _apiService.LogoutAsync();
|
||||
await _gameStateService.ClearLocalGameDataAsync();
|
||||
@@ -134,26 +145,39 @@ public partial class LobbyViewModel : BaseViewModel,
|
||||
});
|
||||
}
|
||||
|
||||
public async void Receive(GameStartedMessage message)
|
||||
public void Receive(GameStartedMessage message)
|
||||
{
|
||||
await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync(nameof(Views.GameBoardPage)));
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Shell.Current.GoToAsync(nameof(Views.GameBoardPage));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Navigation fehlgeschlagen: {ex.Message}";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Receive(ErrorOccurredMessage message)
|
||||
{
|
||||
StatusMessage = message.Value;
|
||||
MainThread.BeginInvokeOnMainThread(() => StatusMessage = message.Value);
|
||||
}
|
||||
|
||||
public void Receive(ConnectionStateChangedMessage message)
|
||||
{
|
||||
IsConnected = message.Value;
|
||||
MainThread.BeginInvokeOnMainThread(() => IsConnected = message.Value);
|
||||
}
|
||||
|
||||
public void Receive(SyncStateChangedMessage message)
|
||||
{
|
||||
IsSyncing = message.Value.IsSyncing;
|
||||
SyncStatusText = message.Value.StatusText;
|
||||
PendingSyncActions = message.Value.PendingActions;
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
IsSyncing = message.Value.IsSyncing;
|
||||
SyncStatusText = message.Value.StatusText;
|
||||
PendingSyncActions = message.Value.PendingActions;
|
||||
});
|
||||
}
|
||||
|
||||
private int GetCurrentPlayerId()
|
||||
|
||||
@@ -58,8 +58,11 @@ public partial class LoginViewModel : BaseViewModel
|
||||
if (!_authSession.IsAuthenticated)
|
||||
return;
|
||||
|
||||
var isSessionValid = await _apiService.ValidateSessionAsync();
|
||||
if (!isSessionValid)
|
||||
var sessionResult = await _apiService.ValidateSessionAsync();
|
||||
if (sessionResult == SessionValidationResult.NetworkError)
|
||||
return;
|
||||
|
||||
if (sessionResult == SessionValidationResult.Invalid)
|
||||
{
|
||||
await _authSession.ClearSessionAsync();
|
||||
await _gameStateService.ClearLocalGameDataAsync();
|
||||
|
||||
@@ -44,7 +44,9 @@ public partial class RegisterViewModel : BaseViewModel
|
||||
});
|
||||
|
||||
await _authSession.SetSessionAsync(response);
|
||||
await _signalR.ConnectAsync(response.Token);
|
||||
var connected = await _signalR.ConnectAsync(response.Token);
|
||||
if (!connected)
|
||||
throw new InvalidOperationException("Verbindung zum Spielserver fehlgeschlagen.");
|
||||
await Shell.Current.GoToAsync(nameof(LobbyPage));
|
||||
}, "Registrierung läuft...");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="SlipItIn.Views.ChallengeNotificationOverlay">
|
||||
xmlns:vm="clr-namespace:SlipItIn.ViewModels"
|
||||
x:Class="SlipItIn.Views.ChallengeNotificationOverlay"
|
||||
x:DataType="vm:GameBoardViewModel">
|
||||
<Grid BackgroundColor="#88000000" IsVisible="{Binding IsChallengeVisible}" Padding="24">
|
||||
<Frame VerticalOptions="Center" HorizontalOptions="Center">
|
||||
<Border VerticalOptions="Center" HorizontalOptions="Center" Stroke="LightGray" StrokeShape="RoundRectangle 12" BackgroundColor="White" Padding="16">
|
||||
<VerticalStackLayout Spacing="10">
|
||||
<Label Text="Du wurdest beschuldigt" FontAttributes="Bold" />
|
||||
<Label Text="{Binding PendingChallenge.ChallengeId, StringFormat='ChallengeId: {0}'}" />
|
||||
@@ -12,6 +14,6 @@
|
||||
<Button Text="Ablehnen" Command="{Binding RejectChallengeCommand}" />
|
||||
</HorizontalStackLayout>
|
||||
</VerticalStackLayout>
|
||||
</Frame>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ContentView>
|
||||
|
||||
@@ -5,9 +5,23 @@ namespace SlipItIn.Views;
|
||||
|
||||
public partial class GameBoardPage : ContentPage
|
||||
{
|
||||
private readonly GameBoardViewModel _viewModel;
|
||||
|
||||
public GameBoardPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = ServiceHelper.GetRequiredService<GameBoardViewModel>();
|
||||
BindingContext = _viewModel = ServiceHelper.GetRequiredService<GameBoardViewModel>();
|
||||
}
|
||||
|
||||
protected override void OnNavigatedTo(NavigatedToEventArgs args)
|
||||
{
|
||||
base.OnNavigatedTo(args);
|
||||
_viewModel.StartRoundTimer();
|
||||
}
|
||||
|
||||
protected override void OnNavigatedFrom(NavigatedFromEventArgs args)
|
||||
{
|
||||
base.OnNavigatedFrom(args);
|
||||
_viewModel.StopRoundTimer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,17 @@ namespace SlipItIn.Views;
|
||||
|
||||
public partial class LobbyPage : ContentPage
|
||||
{
|
||||
private readonly LobbyViewModel _viewModel;
|
||||
|
||||
public LobbyPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = ServiceHelper.GetRequiredService<LobbyViewModel>();
|
||||
BindingContext = _viewModel = ServiceHelper.GetRequiredService<LobbyViewModel>();
|
||||
}
|
||||
|
||||
protected override void OnNavigatedTo(NavigatedToEventArgs args)
|
||||
{
|
||||
base.OnNavigatedTo(args);
|
||||
_viewModel.Activate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"appHost": {
|
||||
"path": "SlipItIN.AppHost/SlipItIN.AppHost.csproj"
|
||||
"path": "SlipItIn.AppHost/SlipItIn.AppHost.csproj"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user