Compare commits
19 Commits
openwiki/u
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 118660a207 | |||
|
|
7953b8ddd0 | ||
|
|
660fc4d311 | ||
|
|
5e22f0ab9b | ||
|
|
af6528ee68 | ||
|
|
7d4344c062 | ||
|
|
510cc0e520 | ||
|
|
b1435b2c1e | ||
|
|
fe1319a34c | ||
|
|
dede059983 | ||
|
|
8078ac2212 | ||
|
|
540cda102f | ||
| bf306a6c15 | |||
|
|
3296ecdfb3 | ||
|
|
6f3c0eb716 | ||
|
|
0f9cc1703a | ||
|
|
936c8bde28 | ||
|
|
2ce9bcd4c4 | ||
|
|
18a9d1ab74 |
40
.github/workflows/build.yml
vendored
Normal file
40
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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: Install MAUI workload
|
||||||
|
run: dotnet workload install maui
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore SlipItIn.slnx
|
||||||
|
|
||||||
|
- name: Build Server-Stack
|
||||||
|
run: |
|
||||||
|
dotnet build SlipItIn.Shared/SlipItIn.Shared.csproj --no-restore --configuration Release
|
||||||
|
dotnet build SlipItIn.ServiceDefaults/SlipItIn.ServiceDefaults.csproj --no-restore --configuration Release
|
||||||
|
dotnet build SlipItIn.Server/SlipItIn.Server.csproj --no-restore --configuration Release
|
||||||
|
dotnet build SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj --no-restore --configuration Release
|
||||||
|
dotnet build SlipItIn.AppHost/SlipItIn.AppHost.csproj --no-restore --configuration Release
|
||||||
|
|
||||||
|
- name: Build MAUI Client
|
||||||
|
run: dotnet build SlipItIn/SlipItIn.csproj --no-restore --configuration Debug
|
||||||
|
|
||||||
|
- name: Run Server Tests
|
||||||
|
run: dotnet test SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj --no-restore --configuration Release
|
||||||
6
.github/workflows/openwiki-update.yml
vendored
6
.github/workflows/openwiki-update.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
|||||||
run: git checkout -- .github/workflows/openwiki-update.yml
|
run: git checkout -- .github/workflows/openwiki-update.yml
|
||||||
|
|
||||||
- name: Create OpenWiki update pull request
|
- name: Create OpenWiki update pull request
|
||||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
uses: infinilabs/gitea-pr@v1
|
||||||
with:
|
with:
|
||||||
add-paths: |
|
add-paths: |
|
||||||
openwiki
|
openwiki
|
||||||
@@ -58,6 +58,10 @@ jobs:
|
|||||||
.github/workflows/openwiki-update.yml
|
.github/workflows/openwiki-update.yml
|
||||||
branch: openwiki/update
|
branch: openwiki/update
|
||||||
commit-message: "docs: update OpenWiki"
|
commit-message: "docs: update OpenWiki"
|
||||||
|
url: ${{ secrets.URL }}
|
||||||
|
token: ${{ secrets.TOKEN }}
|
||||||
|
base_branch: master
|
||||||
|
head_branch: openwiki/update
|
||||||
title: "docs: update OpenWiki"
|
title: "docs: update OpenWiki"
|
||||||
body: |
|
body: |
|
||||||
Automated OpenWiki documentation update.
|
Automated OpenWiki documentation update.
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -361,6 +361,8 @@ MigrationBackup/
|
|||||||
|
|
||||||
# Fody - auto-generated XML schema
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
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
|
||||||
/SlipItIn.Server/slipitIn.db-shm
|
/SlipItIn.Server/slipitIn.db-shm
|
||||||
/SlipItIn.Server/slipitIn.db-wal
|
/SlipItIn.Server/slipitIn.db-wal
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
# Slip It In – Architektur-Updates (Phase 2b & 3b)
|
|
||||||
|
|
||||||
Dieses Dokument fasst die Sicherheits- und Stabilitäts-Erweiterungen zusammen, die dem ProjectPlan.md hinzugefügt wurden.
|
|
||||||
|
|
||||||
## 🔒 Backend-Sicherheit (Phase 2b)
|
|
||||||
|
|
||||||
### 1. JWT-Claims Validation im GameHub
|
|
||||||
- **Problem**: Clients könnten falsche PlayerId-Claims senden (Spoofing)
|
|
||||||
- **Lösung**: `GetAuthenticatedUserId()` extrahiert User-ID aus JWT-Claims
|
|
||||||
- **Implementierung**: Jede Hub-Methode validiert `Context.User?.FindFirst(ClaimTypes.NameIdentifier)`
|
|
||||||
|
|
||||||
### 2. IDbContextFactory statt Scoped DbContext
|
|
||||||
- **Problem**: Bei parallelen SignalR-Aufrufen kann es zu Race Conditions kommen
|
|
||||||
- **Lösung**: `IDbContextFactory<SlipItInDbContext>` erzeugt isolierte Sessions pro Aufruf
|
|
||||||
- **Vorteil**: Phrase-Transfer ist thread-safe, keine Conflicts bei 2 gleichzeitigen Challenges
|
|
||||||
|
|
||||||
### 3. Serverseitige Validierung
|
|
||||||
- **Regel**: Clients senden nur "Ich möchte X machen", Server prüft ALLES
|
|
||||||
- **Beispiel**: `SubmitSlipAsync()` prüft auf dem Server:
|
|
||||||
- ✅ Gehört die Phrase dem Spieler?
|
|
||||||
- ✅ Ist die Runde aktiv?
|
|
||||||
- ✅ Ist der Spieler noch im Spiel?
|
|
||||||
|
|
||||||
### 4. Daten-Isolation (GameStateDto vs. PlayerHandDto)
|
|
||||||
- **GameStateDto**: Öffentliche Daten (Username, Score, CardCount) → für ALLE sichtbar
|
|
||||||
- **PlayerHandDto**: Private Daten (Text der eigenen Karten) → nur für den Spieler selbst
|
|
||||||
- **Ergebnis**: Gegner sehen nicht, welche Phrasen ich habe!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📱 Client-Stabilität (Phase 3b)
|
|
||||||
|
|
||||||
### 1. WeakReferenceMessenger für Event-Entkopplung
|
|
||||||
- **Problem**: Services rufen ViewModels direkt auf → Memory Leaks wenn VM disposed
|
|
||||||
- **Lösung**: Services senden Messages via `WeakReferenceMessenger.Default.Send()`
|
|
||||||
- **Vorteil**: ViewModels können sich abmelden ohne zirkuläre Abhängigkeiten
|
|
||||||
|
|
||||||
**Beispiel:**
|
|
||||||
```csharp
|
|
||||||
// Service sendet Event
|
|
||||||
WeakReferenceMessenger.Default.Send(new GameStateChangedMessage(newState));
|
|
||||||
|
|
||||||
// ViewModel empfängt (mit auto-cleanup beim Dispose)
|
|
||||||
WeakReferenceMessenger.Default.Register<GameStateChangedMessage>(this, (r, m) =>
|
|
||||||
{
|
|
||||||
CurrentGameState = m.Value;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Automatischer Reconnect
|
|
||||||
- **Strategie**: Exponential Backoff (0s → 2s → 10s → 30s)
|
|
||||||
- **Pufferung**: Offline-Aktionen werden in lokale Queue geschrieben
|
|
||||||
- **Resync**: Nach Reconnect wird `ResyncAsync()` aufgerufen
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
1. SignalR-Verbindung getrennt
|
|
||||||
2. Client speichert "Slip zu Phrase X" in lokale Queue
|
|
||||||
3. Nach 2s Automatischer Reconnect-Versuch
|
|
||||||
4. Nach erfolgreichem Reconnect: Queued Actions werden abgesendet
|
|
||||||
|
|
||||||
### 3. State Restoration
|
|
||||||
- **App-Pause**: `GameStateService` speichert aktuellen State in `SecureStorage`
|
|
||||||
- **App-Resume**: `ResyncAsync()` wird aufgerufen
|
|
||||||
- ✅ Holt aktuellen GameState vom Server
|
|
||||||
- ✅ Sendet gepufferte Offline-Aktionen erneut
|
|
||||||
- ✅ UI zeigt "Syncing..." bis fertig
|
|
||||||
|
|
||||||
### 4. Dual-Layer Storage
|
|
||||||
- **In-Memory Layer**: `GameStateService` halten aktuellen State
|
|
||||||
- **Persistent Layer**: `SecureStorage`/`Preferences` speichern Backup
|
|
||||||
- **Fehler-Handling**: Nach App-Crash kann State aus Persistent Layer wiederhergestellt werden
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Schnellreferenz: Neue Components
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
```
|
|
||||||
GameHub
|
|
||||||
├─ GetAuthenticatedUserId() → JWT-Validierung
|
|
||||||
├─ ValidatePlayerAccessAsync() → Authorization Check
|
|
||||||
└─ Alle Methoden nutzen IDbContextFactory
|
|
||||||
|
|
||||||
Program.cs
|
|
||||||
├─ AddDbContextFactory<SlipItInDbContext>()
|
|
||||||
├─ AddAuthentication(JwtBearerDefaults)
|
|
||||||
└─ AddSignalR() mit JWT-Filter
|
|
||||||
|
|
||||||
DTOs
|
|
||||||
├─ GameStateDto (öffentlich)
|
|
||||||
├─ PlayerHandDto (privat)
|
|
||||||
└─ Keine CardText in GameStateDto!
|
|
||||||
```
|
|
||||||
|
|
||||||
### Client
|
|
||||||
```
|
|
||||||
GameStateService
|
|
||||||
├─ CurrentGameState (in-memory)
|
|
||||||
├─ CurrentPlayerHand (in-memory)
|
|
||||||
├─ UpdateGameStateAsync() → speichert + sendet Message
|
|
||||||
└─ ResyncAsync() → bei App-Resume
|
|
||||||
|
|
||||||
SignalRService
|
|
||||||
├─ ConnectAsync(token) mit Auto-Reconnect
|
|
||||||
├─ RequestGameStateAsync()
|
|
||||||
└─ On<> Handler senden Messages
|
|
||||||
|
|
||||||
Messages (WeakReferenceMessenger)
|
|
||||||
├─ GameStateChangedMessage
|
|
||||||
├─ PlayerHandChangedMessage
|
|
||||||
└─ ChallengeReceivedMessage
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Wichtigste Tests
|
|
||||||
|
|
||||||
| Test | Ziel |
|
|
||||||
|------|------|
|
|
||||||
| **JWT-Auth** | Hub lehnt Requests ohne gültiges Token ab |
|
|
||||||
| **Player-Access** | User A kann nicht auf Players von User B zugreifen |
|
|
||||||
| **Race Condition** | Phrase-Transfer ist thread-safe bei 2 gleichzeitigen Challenges |
|
|
||||||
| **Offline-Queue** | Aktionen während Netzwerk-Ausfall werden gepuffert und später gesendet |
|
|
||||||
| **Memory Leaks** | Keine Leaks wenn ViewModels disposed werden (WeakReference) |
|
|
||||||
| **State-Sync** | App-Resume synct korrekt mit Server-State |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Implementierungs-Reihenfolge
|
|
||||||
|
|
||||||
1. **Backend zuerst**: JWT-Auth + IDbContextFactory konfigurieren
|
|
||||||
2. **GameService erweitern**: Serverseitige Validierung in alle Methoden
|
|
||||||
3. **Client-Services**: GameStateService + SignalRService implementieren
|
|
||||||
4. **Messaging**: WeakReferenceMessenger in ViewModels integrieren
|
|
||||||
5. **Tests**: Integrationstest-Suite schreiben
|
|
||||||
6. **UI**: Views an neue Message-Events binden
|
|
||||||
File diff suppressed because it is too large
Load Diff
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,8 +1,7 @@
|
|||||||
var builder = DistributedApplication.CreateBuilder(args);
|
var builder = DistributedApplication.CreateBuilder(args);
|
||||||
|
|
||||||
var db = builder.AddPostgres("pgsql")
|
var db = builder.AddPostgres("pgsql")
|
||||||
.AddDatabase("postgresdb")
|
.AddDatabase("postgresdb");
|
||||||
;
|
|
||||||
|
|
||||||
var server = builder.AddProject<Projects.SlipItIn_Server>("server")
|
var server = builder.AddProject<Projects.SlipItIn_Server>("server")
|
||||||
.WithReference(db)
|
.WithReference(db)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.4.6" />
|
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.5.2" />
|
||||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.4.6" />
|
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.5.2" />
|
||||||
<PackageReference Include="MessagePack" Version="3.1.8" />
|
<PackageReference Include="MessagePack" Version="3.1.8" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
212
SlipItIn.Server.Tests/AuthControllerTests.cs
Normal file
212
SlipItIn.Server.Tests/AuthControllerTests.cs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using SlipItIn.Server.Controllers;
|
||||||
|
using SlipItIn.Server.Data;
|
||||||
|
using SlipItIn.Shared.DTOs;
|
||||||
|
using SlipItIn.Shared.Models;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace SlipItIn.Server.Tests;
|
||||||
|
|
||||||
|
public class AuthControllerTests
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<SlipItInDbContext> _factory = TestDbHelper.CreateFactory();
|
||||||
|
private readonly AuthController _sut;
|
||||||
|
|
||||||
|
public AuthControllerTests()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Jwt:Key"] = "TestKeyThatIsAtLeast32CharactersLong!!",
|
||||||
|
["Jwt:Issuer"] = "TestIssuer",
|
||||||
|
["Jwt:Audience"] = "TestAudience",
|
||||||
|
["Jwt:ExpirationMinutes"] = "60"
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
_sut = new AuthController(_factory, config, new TestHostEnvironment());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestHostEnvironment : IHostEnvironment
|
||||||
|
{
|
||||||
|
public string EnvironmentName { get; set; } = Environments.Development;
|
||||||
|
public string ApplicationName { get; set; } = "SlipItIn.Server.Tests";
|
||||||
|
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
|
||||||
|
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Register ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Register_ReturnsOk_WithTokenAndUserData()
|
||||||
|
{
|
||||||
|
var result = await _sut.Register(new RegisterRequestDto
|
||||||
|
{
|
||||||
|
Username = " Alice ",
|
||||||
|
Email = " ALICE@Test.Local ",
|
||||||
|
Password = "Secret123!"
|
||||||
|
});
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var dto = Assert.IsType<AuthResponseDto>(ok.Value);
|
||||||
|
Assert.Equal("Alice", dto.Username); // getrimmt
|
||||||
|
Assert.Equal("alice@test.local", dto.Email); // getrimmt + lowercase
|
||||||
|
Assert.True(dto.UserId > 0);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(dto.Token));
|
||||||
|
Assert.True(dto.ExpiresAtUtc > DateTime.UtcNow);
|
||||||
|
|
||||||
|
// Token enthält die erwarteten Claims und ist valide lesbar
|
||||||
|
var token = new JwtSecurityTokenHandler().ReadJwtToken(dto.Token);
|
||||||
|
Assert.Equal("TestIssuer", token.Issuer);
|
||||||
|
Assert.Contains("TestAudience", token.Audiences);
|
||||||
|
Assert.Equal(dto.UserId.ToString(), token.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", "a@b.c", "pw")]
|
||||||
|
[InlineData("user", "", "pw")]
|
||||||
|
[InlineData("user", "a@b.c", "")]
|
||||||
|
[InlineData(" ", "a@b.c", "pw")]
|
||||||
|
public async Task Register_ReturnsBadRequest_WhenFieldsMissing(string username, string email, string password)
|
||||||
|
{
|
||||||
|
var result = await _sut.Register(new RegisterRequestDto { Username = username, Email = email, Password = password });
|
||||||
|
|
||||||
|
Assert.IsType<BadRequestObjectResult>(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Register_ReturnsConflict_WhenEmailExists_CaseInsensitive()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
TestDbHelper.SeedUser(db, "existing", "alice@test.local");
|
||||||
|
|
||||||
|
var result = await _sut.Register(new RegisterRequestDto
|
||||||
|
{
|
||||||
|
Username = "newuser",
|
||||||
|
Email = "ALICE@TEST.LOCAL",
|
||||||
|
Password = "Secret123!"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Register_ReturnsConflict_WhenUsernameExists_CaseInsensitive()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
TestDbHelper.SeedUser(db, "alice", "other@test.local");
|
||||||
|
|
||||||
|
var result = await _sut.Register(new RegisterRequestDto
|
||||||
|
{
|
||||||
|
Username = "ALICE",
|
||||||
|
Email = "new@test.local",
|
||||||
|
Password = "Secret123!"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Login ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_ReturnsOk_WithValidCredentials()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
db.Users.Add(new User
|
||||||
|
{
|
||||||
|
Username = "bob",
|
||||||
|
Email = "bob@test.local",
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("Secret123!")
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.Login(new LoginRequestDto { Email = " BOB@test.local ", Password = "Secret123!" });
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var dto = Assert.IsType<AuthResponseDto>(ok.Value);
|
||||||
|
Assert.Equal("bob", dto.Username);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(dto.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", "pw")]
|
||||||
|
[InlineData("a@b.c", "")]
|
||||||
|
public async Task Login_ReturnsBadRequest_WhenFieldsMissing(string email, string password)
|
||||||
|
{
|
||||||
|
var result = await _sut.Login(new LoginRequestDto { Email = email, Password = password });
|
||||||
|
|
||||||
|
Assert.IsType<BadRequestObjectResult>(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_ReturnsUnauthorized_WhenUserNotFound()
|
||||||
|
{
|
||||||
|
var result = await _sut.Login(new LoginRequestDto { Email = "ghost@test.local", Password = "Secret123!" });
|
||||||
|
|
||||||
|
Assert.IsType<UnauthorizedObjectResult>(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_ReturnsUnauthorized_WhenPasswordWrong()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
db.Users.Add(new User
|
||||||
|
{
|
||||||
|
Username = "bob",
|
||||||
|
Email = "bob@test.local",
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("CorrectPassword")
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.Login(new LoginRequestDto { Email = "bob@test.local", Password = "WrongPassword" });
|
||||||
|
|
||||||
|
Assert.IsType<UnauthorizedObjectResult>(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Me ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Me_ReturnsUserData_FromClaims()
|
||||||
|
{
|
||||||
|
var identity = new ClaimsIdentity([
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "42"),
|
||||||
|
new Claim(ClaimTypes.Name, "alice"),
|
||||||
|
new Claim(ClaimTypes.Email, "alice@test.local")
|
||||||
|
], "TestAuth");
|
||||||
|
_sut.ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(identity)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = _sut.Me();
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var value = ok.Value!;
|
||||||
|
Assert.Equal(42, (int)value.GetType().GetProperty("UserId")!.GetValue(value)!);
|
||||||
|
Assert.Equal("alice", (string)value.GetType().GetProperty("Username")!.GetValue(value)!);
|
||||||
|
Assert.Equal("alice@test.local", (string)value.GetType().GetProperty("Email")!.GetValue(value)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Me_ReturnsUnauthorized_WhenNameIdentifierClaimMissing()
|
||||||
|
{
|
||||||
|
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, "alice")], "TestAuth");
|
||||||
|
_sut.ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(identity)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = _sut.Me();
|
||||||
|
|
||||||
|
Assert.IsType<UnauthorizedResult>(result.Result);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
SlipItIn.Server.Tests/DbSeederTests.cs
Normal file
61
SlipItIn.Server.Tests/DbSeederTests.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using SlipItIn.Server.Data;
|
||||||
|
|
||||||
|
namespace SlipItIn.Server.Tests;
|
||||||
|
|
||||||
|
public class DbSeederTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedAsync_SeedsUsersAndPhrases_WhenDatabaseIsEmpty()
|
||||||
|
{
|
||||||
|
var factory = TestDbHelper.CreateFactory();
|
||||||
|
using var db = factory.CreateDbContext();
|
||||||
|
|
||||||
|
await DbSeeder.SeedAsync(db);
|
||||||
|
|
||||||
|
Assert.Equal(2, db.Users.Count());
|
||||||
|
Assert.True(db.Phrases.Count() >= 5);
|
||||||
|
Assert.All(db.Phrases, p => Assert.True(p.IsActive));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedAsync_DoesNothing_WhenUsersAlreadyExist()
|
||||||
|
{
|
||||||
|
var factory = TestDbHelper.CreateFactory();
|
||||||
|
using var db = factory.CreateDbContext();
|
||||||
|
TestDbHelper.SeedUser(db);
|
||||||
|
|
||||||
|
await DbSeeder.SeedAsync(db);
|
||||||
|
|
||||||
|
Assert.Equal(1, db.Users.Count());
|
||||||
|
Assert.Empty(db.Phrases);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedAsync_DoesNothing_WhenPhrasesAlreadyExist()
|
||||||
|
{
|
||||||
|
var factory = TestDbHelper.CreateFactory();
|
||||||
|
using var db = factory.CreateDbContext();
|
||||||
|
TestDbHelper.SeedPhrases(db, 1);
|
||||||
|
|
||||||
|
await DbSeeder.SeedAsync(db);
|
||||||
|
|
||||||
|
Assert.Equal(1, db.Users.Count()); // nur der Creator-User des Phrase-Seedings
|
||||||
|
Assert.Equal(1, db.Phrases.Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedAsync_IsIdempotent_WhenCalledTwice()
|
||||||
|
{
|
||||||
|
var factory = TestDbHelper.CreateFactory();
|
||||||
|
using var db = factory.CreateDbContext();
|
||||||
|
|
||||||
|
await DbSeeder.SeedAsync(db);
|
||||||
|
var userCount = db.Users.Count();
|
||||||
|
var phraseCount = db.Phrases.Count();
|
||||||
|
|
||||||
|
await DbSeeder.SeedAsync(db);
|
||||||
|
|
||||||
|
Assert.Equal(userCount, db.Users.Count());
|
||||||
|
Assert.Equal(phraseCount, db.Phrases.Count());
|
||||||
|
}
|
||||||
|
}
|
||||||
606
SlipItIn.Server.Tests/GameHubTests.cs
Normal file
606
SlipItIn.Server.Tests/GameHubTests.cs
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using SlipItIn.Server.Data;
|
||||||
|
using SlipItIn.Server.Hubs;
|
||||||
|
using SlipItIn.Server.Services;
|
||||||
|
using SlipItIn.Shared.DTOs;
|
||||||
|
using SlipItIn.Shared.Models;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace SlipItIn.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests für den GameHub mit gemocktem IGameService und gemockten SignalR-Abhängigkeiten.
|
||||||
|
/// Echte DB (InMemory) wird nur für die Validierungs-Methoden des Hubs verwendet.
|
||||||
|
/// </summary>
|
||||||
|
public class GameHubTests
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<SlipItInDbContext> _factory = TestDbHelper.CreateFactory();
|
||||||
|
private readonly Mock<IGameService> _gameService = new();
|
||||||
|
private readonly Mock<IGroupManager> _groups = new();
|
||||||
|
private readonly Mock<IHubCallerClients> _clients = new();
|
||||||
|
private readonly Mock<ISingleClientProxy> _callerProxy = new();
|
||||||
|
private readonly Mock<IClientProxy> _groupProxy = new();
|
||||||
|
private readonly Mock<ISingleClientProxy> _clientProxy = new();
|
||||||
|
private readonly GameHub _hub;
|
||||||
|
|
||||||
|
private const string ConnectionId = "test-connection-id";
|
||||||
|
private int _userId = 1;
|
||||||
|
|
||||||
|
public GameHubTests()
|
||||||
|
{
|
||||||
|
_clients.Setup(c => c.Caller).Returns(_callerProxy.Object);
|
||||||
|
_clients.Setup(c => c.Group(It.IsAny<string>())).Returns(_groupProxy.Object);
|
||||||
|
_clients.Setup(c => c.Client(It.IsAny<string>())).Returns(_clientProxy.Object);
|
||||||
|
|
||||||
|
_hub = new GameHub(_gameService.Object, _factory, NullLogger<GameHub>.Instance);
|
||||||
|
SetupContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupContext()
|
||||||
|
{
|
||||||
|
var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, _userId.ToString())], "TestAuth");
|
||||||
|
var context = new Mock<HubCallerContext>();
|
||||||
|
context.Setup(c => c.ConnectionId).Returns(ConnectionId);
|
||||||
|
context.Setup(c => c.User).Returns(new ClaimsPrincipal(identity));
|
||||||
|
_hub.Context = context.Object;
|
||||||
|
_hub.Clients = _clients.Object;
|
||||||
|
_hub.Groups = _groups.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WithoutAuth()
|
||||||
|
{
|
||||||
|
var context = new Mock<HubCallerContext>();
|
||||||
|
context.Setup(c => c.ConnectionId).Returns(ConnectionId);
|
||||||
|
context.Setup(c => c.User).Returns((ClaimsPrincipal?)null);
|
||||||
|
_hub.Context = context.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ErrorMessageOf(Mock<ISingleClientProxy> proxy)
|
||||||
|
{
|
||||||
|
// Die Aufrufe kommen als SendCoreAsync(string, object?[]) mit dem Payload in args[1] rein.
|
||||||
|
var invocation = proxy.Invocations.FirstOrDefault(i => i.Arguments.Count >= 2 && i.Arguments[0] is string m && m == "Error");
|
||||||
|
if (invocation == null) return null;
|
||||||
|
var payload = invocation.Arguments[1];
|
||||||
|
// Payload kann ein anonymes Objekt sein oder ein object?[] mit einem anonymen Objekt drin.
|
||||||
|
if (payload is object?[] arr && arr.Length > 0) payload = arr[0];
|
||||||
|
return payload?.GetType().GetProperty("Message")?.GetValue(payload) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Game SeedGameWithPlayers(params (User user, string? connId)[] players)
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var game = new Game { LobbyCode = "ABC123", HostId = players[0].user.Id, Status = GameStatus.Lobby };
|
||||||
|
db.Games.Add(game);
|
||||||
|
foreach (var (user, connId) in players)
|
||||||
|
{
|
||||||
|
db.Players.Add(new Player { UserId = user.Id, Game = game, ConnectionId = connId });
|
||||||
|
}
|
||||||
|
db.SaveChanges();
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (User user, Player player) SeedUserWithPlayer(Game game)
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db, Guid.NewGuid().ToString("N")[..8], Guid.NewGuid().ToString("N")[..8] + "@t.local");
|
||||||
|
var player = new Player { UserId = user.Id, GameId = game.Id };
|
||||||
|
db.Players.Add(player);
|
||||||
|
db.SaveChanges();
|
||||||
|
return (user, player);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- CreateLobby ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateLobby_AddsCallerToGroupAndSendsLobbyCreated()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db);
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
|
||||||
|
var game = new Game { Id = 7, LobbyCode = "XYZ789", HostId = user.Id };
|
||||||
|
_gameService.Setup(s => s.CreateGameAsync(user.Id, ConnectionId)).ReturnsAsync(game);
|
||||||
|
|
||||||
|
await _hub.CreateLobby();
|
||||||
|
|
||||||
|
_groups.Verify(g => g.AddToGroupAsync(ConnectionId, "XYZ789", default), Times.Once);
|
||||||
|
_callerProxy.Verify(p => p.SendCoreAsync("LobbyCreated", It.Is<object?[]>(a => a.Length == 1), default), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateLobby_SendsUnauthorized_WhenNotAuthenticated()
|
||||||
|
{
|
||||||
|
WithoutAuth();
|
||||||
|
|
||||||
|
await _hub.CreateLobby();
|
||||||
|
|
||||||
|
Assert.Equal("Unauthorized", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateLobby_SendsGenericError_WhenServiceThrows()
|
||||||
|
{
|
||||||
|
_gameService.Setup(s => s.CreateGameAsync(It.IsAny<int>(), It.IsAny<string?>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Host not found"));
|
||||||
|
|
||||||
|
await _hub.CreateLobby();
|
||||||
|
|
||||||
|
Assert.Equal("An error occurred while creating the lobby.", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- JoinLobby ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task JoinLobby_AddsToGroupAndBroadcastsPlayerJoined()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db);
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
|
||||||
|
var game = new Game { Id = 3, LobbyCode = "JOIN42", HostId = 99 };
|
||||||
|
_gameService.Setup(s => s.JoinGameAsync("JOIN42", user.Id, ConnectionId)).ReturnsAsync(game);
|
||||||
|
_gameService.Setup(s => s.GetGameStateAsync(game.Id)).ReturnsAsync(new GameStateDto { GameId = 3 });
|
||||||
|
|
||||||
|
await _hub.JoinLobby("JOIN42");
|
||||||
|
|
||||||
|
_groups.Verify(g => g.AddToGroupAsync(ConnectionId, "JOIN42", default), Times.Once);
|
||||||
|
_groupProxy.Verify(p => p.SendCoreAsync("PlayerJoined", It.IsAny<object?[]>(), default), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task JoinLobby_SendsUnauthorized_WhenNotAuthenticated()
|
||||||
|
{
|
||||||
|
WithoutAuth();
|
||||||
|
|
||||||
|
await _hub.JoinLobby("ANY");
|
||||||
|
|
||||||
|
Assert.Equal("Unauthorized", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task JoinLobby_SendsServiceMessage_WhenGameFullOrStarted()
|
||||||
|
{
|
||||||
|
_gameService.Setup(s => s.JoinGameAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<string?>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Game is full"));
|
||||||
|
|
||||||
|
await _hub.JoinLobby("FULL1");
|
||||||
|
|
||||||
|
Assert.Equal("Game is full", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task JoinLobby_SendsGenericError_WhenUnexpectedException()
|
||||||
|
{
|
||||||
|
_gameService.Setup(s => s.JoinGameAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<string?>()))
|
||||||
|
.ThrowsAsync(new Exception("boom"));
|
||||||
|
|
||||||
|
await _hub.JoinLobby("ANY");
|
||||||
|
|
||||||
|
Assert.Equal("An error occurred while joining the lobby.", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- PlayerReady ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PlayerReady_BroadcastsGameState_WhenValid()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db);
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
var game = SeedGameWithPlayers((user, ConnectionId));
|
||||||
|
var player = db.Players.Single(p => p.GameId == game.Id);
|
||||||
|
|
||||||
|
_gameService.Setup(s => s.SetPlayerReadyAsync(game.Id, player.Id))
|
||||||
|
.ReturnsAsync(new Game { Id = game.Id, LobbyCode = game.LobbyCode });
|
||||||
|
_gameService.Setup(s => s.GetGameStateAsync(game.Id)).ReturnsAsync(new GameStateDto { GameId = game.Id });
|
||||||
|
|
||||||
|
await _hub.PlayerReady(game.Id, player.Id);
|
||||||
|
|
||||||
|
_groupProxy.Verify(p => p.SendCoreAsync("GameStateUpdated", It.IsAny<object?[]>(), default), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PlayerReady_SendsError_WhenPlayerBelongsToOtherUser()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db, "me", "me@t.local");
|
||||||
|
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
var game = SeedGameWithPlayers((other, null));
|
||||||
|
var otherPlayer = db.Players.Single(p => p.GameId == game.Id);
|
||||||
|
|
||||||
|
await _hub.PlayerReady(game.Id, otherPlayer.Id);
|
||||||
|
|
||||||
|
Assert.Equal("Player does not belong to authenticated user", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PlayerReady_SendsGenericError_WhenServiceThrows()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db);
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
var game = SeedGameWithPlayers((user, ConnectionId));
|
||||||
|
var player = db.Players.Single(p => p.GameId == game.Id);
|
||||||
|
|
||||||
|
_gameService.Setup(s => s.SetPlayerReadyAsync(It.IsAny<int>(), It.IsAny<int>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Player not found"));
|
||||||
|
|
||||||
|
await _hub.PlayerReady(game.Id, player.Id);
|
||||||
|
|
||||||
|
Assert.Equal("An error occurred while setting player status.", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- StartGame ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartGame_BroadcastsGameStarted_AndSendsHandsToEachPlayer()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
|
||||||
|
_userId = host.Id;
|
||||||
|
SetupContext();
|
||||||
|
var game = SeedGameWithPlayers((host, "conn-host"));
|
||||||
|
var (_, otherPlayer) = SeedUserWithPlayer(game);
|
||||||
|
using (var db2 = _factory.CreateDbContext())
|
||||||
|
{
|
||||||
|
db2.Players.Single(p => p.Id == otherPlayer.Id).ConnectionId = "conn-other";
|
||||||
|
db2.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
_gameService.Setup(s => s.StartGameAsync(game.Id)).ReturnsAsync(new Game { Id = game.Id, LobbyCode = game.LobbyCode });
|
||||||
|
_gameService.Setup(s => s.DealCardsAsync(game.Id)).ReturnsAsync([
|
||||||
|
new PlayerCard { PlayerId = game.Players.First().Id },
|
||||||
|
new PlayerCard { PlayerId = otherPlayer.Id }
|
||||||
|
]);
|
||||||
|
_gameService.Setup(s => s.GetPlayerHandAsync(It.IsAny<int>())).ReturnsAsync((int pid) => new PlayerHandDto { PlayerId = pid });
|
||||||
|
|
||||||
|
await _hub.StartGame(game.Id);
|
||||||
|
|
||||||
|
_groupProxy.Verify(p => p.SendCoreAsync("GameStarted", It.IsAny<object?[]>(), default), Times.Once);
|
||||||
|
// Beide Spieler erhalten ihre Hand (über Clients.Client)
|
||||||
|
_clients.Verify(c => c.Client("conn-host"), Times.Once);
|
||||||
|
_clients.Verify(c => c.Client("conn-other"), Times.Once);
|
||||||
|
_clientProxy.Verify(p => p.SendCoreAsync("PlayerHandUpdated", It.IsAny<object?[]>(), default), Times.Exactly(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartGame_SendsHostOnlyError_WhenCallerIsNotHost()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
|
||||||
|
var notHost = TestDbHelper.SeedUser(db, "not", "not@t.local");
|
||||||
|
_userId = notHost.Id;
|
||||||
|
SetupContext();
|
||||||
|
var game = SeedGameWithPlayers((host, null), (notHost, null));
|
||||||
|
|
||||||
|
await _hub.StartGame(game.Id);
|
||||||
|
|
||||||
|
Assert.Equal("Only the host can start the game.", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartGame_SendsGenericError_WhenServiceThrows()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
_userId = host.Id;
|
||||||
|
SetupContext();
|
||||||
|
var game = SeedGameWithPlayers((host, null));
|
||||||
|
|
||||||
|
_gameService.Setup(s => s.StartGameAsync(It.IsAny<int>())).ThrowsAsync(new Exception("boom"));
|
||||||
|
|
||||||
|
await _hub.StartGame(game.Id);
|
||||||
|
|
||||||
|
Assert.Equal("An error occurred while starting the game.", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- SubmitSlip ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubmitSlip_BroadcastsSlipSubmitted_WhenValid()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db);
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
var game = SeedGameWithPlayers((user, ConnectionId));
|
||||||
|
var player = db.Players.Single(p => p.GameId == game.Id);
|
||||||
|
|
||||||
|
_gameService.Setup(s => s.SubmitSlipAsync(game.Id, player.Id, 55)).ReturnsAsync(true);
|
||||||
|
_gameService.Setup(s => s.GetGameAsync(game.Id)).ReturnsAsync(new Game { Id = game.Id, LobbyCode = game.LobbyCode });
|
||||||
|
|
||||||
|
await _hub.SubmitSlip(game.Id, player.Id, 55);
|
||||||
|
|
||||||
|
_groupProxy.Verify(p => p.SendCoreAsync("SlipSubmitted", It.IsAny<object?[]>(), default), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubmitSlip_SendsError_WhenPlayerNotOwnedByUser()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db, "me", "me@t.local");
|
||||||
|
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
var game = SeedGameWithPlayers((other, null));
|
||||||
|
var otherPlayer = db.Players.Single(p => p.GameId == game.Id);
|
||||||
|
|
||||||
|
await _hub.SubmitSlip(game.Id, otherPlayer.Id, 1);
|
||||||
|
|
||||||
|
Assert.Equal("Player does not belong to authenticated user", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubmitSlip_SendsGenericError_WhenServiceThrows()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db);
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
var game = SeedGameWithPlayers((user, ConnectionId));
|
||||||
|
var player = db.Players.Single(p => p.GameId == game.Id);
|
||||||
|
|
||||||
|
_gameService.Setup(s => s.SubmitSlipAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Card not found"));
|
||||||
|
|
||||||
|
await _hub.SubmitSlip(game.Id, player.Id, 999);
|
||||||
|
|
||||||
|
Assert.Equal("An error occurred while submitting slip.", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- ChallengeSlip ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChallengeSlip_BroadcastsAndNotifiesTargetPlayer()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var challenger = TestDbHelper.SeedUser(db, "ch", "ch@t.local");
|
||||||
|
_userId = challenger.Id;
|
||||||
|
SetupContext();
|
||||||
|
var game = SeedGameWithPlayers((challenger, ConnectionId));
|
||||||
|
var (_, targetPlayer) = SeedUserWithPlayer(game);
|
||||||
|
using (var db2 = _factory.CreateDbContext())
|
||||||
|
{
|
||||||
|
db2.Players.Single(p => p.Id == targetPlayer.Id).ConnectionId = "conn-target";
|
||||||
|
db2.SaveChanges();
|
||||||
|
}
|
||||||
|
var challengerPlayer = db.Players.Single(p => p.UserId == challenger.Id);
|
||||||
|
|
||||||
|
_gameService.Setup(s => s.CreateChallengeAsync(game.Id, challengerPlayer.Id, 42))
|
||||||
|
.ReturnsAsync(new SlipChallenge
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
ChallengingPlayerId = challengerPlayer.Id,
|
||||||
|
TargetPlayerId = targetPlayer.Id,
|
||||||
|
TargetCardId = 42,
|
||||||
|
Status = ChallengeStatus.Pending
|
||||||
|
});
|
||||||
|
_gameService.Setup(s => s.GetGameAsync(game.Id)).ReturnsAsync(new Game { Id = game.Id, LobbyCode = game.LobbyCode });
|
||||||
|
|
||||||
|
await _hub.ChallengeSlip(game.Id, challengerPlayer.Id, 42);
|
||||||
|
|
||||||
|
_groupProxy.Verify(p => p.SendCoreAsync("SlipChallenged", It.IsAny<object?[]>(), default), Times.Once);
|
||||||
|
_clients.Verify(c => c.Client("conn-target"), Times.Once);
|
||||||
|
_clientProxy.Verify(p => p.SendCoreAsync("ChallengeReceived", It.IsAny<object?[]>(), default), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChallengeSlip_SendsError_WhenPlayerNotOwnedByUser()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db, "me", "me@t.local");
|
||||||
|
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
var game = SeedGameWithPlayers((other, null));
|
||||||
|
var otherPlayer = db.Players.Single(p => p.GameId == game.Id);
|
||||||
|
|
||||||
|
await _hub.ChallengeSlip(game.Id, otherPlayer.Id, 1);
|
||||||
|
|
||||||
|
Assert.Equal("Player does not belong to authenticated user", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChallengeSlip_SendsGenericError_WhenServiceThrows()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db);
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
var game = SeedGameWithPlayers((user, ConnectionId));
|
||||||
|
var player = db.Players.Single(p => p.GameId == game.Id);
|
||||||
|
|
||||||
|
_gameService.Setup(s => s.CreateChallengeAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("No active round found"));
|
||||||
|
|
||||||
|
await _hub.ChallengeSlip(game.Id, player.Id, 1);
|
||||||
|
|
||||||
|
Assert.Equal("An error occurred while challenging slip.", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- ResolveChallenge ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveChallenge_BroadcastsAndUpdatesHands_WhenTargetResolves()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var target = TestDbHelper.SeedUser(db, "target", "target@t.local");
|
||||||
|
_userId = target.Id;
|
||||||
|
SetupContext();
|
||||||
|
var game = SeedGameWithPlayers((target, "conn-target"));
|
||||||
|
var targetPlayer = db.Players.Single(p => p.UserId == target.Id);
|
||||||
|
var (_, challengingPlayer) = SeedUserWithPlayer(game);
|
||||||
|
using (var db2 = _factory.CreateDbContext())
|
||||||
|
{
|
||||||
|
db2.Players.Single(p => p.Id == challengingPlayer.Id).ConnectionId = "conn-challenger";
|
||||||
|
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
|
||||||
|
db2.GameRounds.Add(round);
|
||||||
|
var phrase = TestDbHelper.SeedPhrases(db2, 1).Single();
|
||||||
|
var card = new PlayerCard { PlayerId = targetPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id };
|
||||||
|
db2.PlayerCards.Add(card);
|
||||||
|
db2.SlipChallenges.Add(new SlipChallenge
|
||||||
|
{
|
||||||
|
Id = 77,
|
||||||
|
GameRoundId = round.Id,
|
||||||
|
ChallengingPlayerId = challengingPlayer.Id,
|
||||||
|
TargetPlayerId = targetPlayer.Id,
|
||||||
|
TargetCardId = card.Id,
|
||||||
|
Status = ChallengeStatus.Pending
|
||||||
|
});
|
||||||
|
db2.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
_gameService.Setup(s => s.ResolveChallengeAsync(77, true))
|
||||||
|
.ReturnsAsync(new SlipChallenge
|
||||||
|
{
|
||||||
|
Id = 77,
|
||||||
|
GameRound = new GameRound { GameId = game.Id },
|
||||||
|
ChallengingPlayerId = challengingPlayer.Id,
|
||||||
|
TargetPlayerId = targetPlayer.Id,
|
||||||
|
Status = ChallengeStatus.Approved
|
||||||
|
});
|
||||||
|
_gameService.Setup(s => s.GetGameAsync(game.Id)).ReturnsAsync(new Game { Id = game.Id, LobbyCode = game.LobbyCode });
|
||||||
|
_gameService.Setup(s => s.GetPlayerHandAsync(It.IsAny<int>())).ReturnsAsync((int pid) => new PlayerHandDto { PlayerId = pid });
|
||||||
|
|
||||||
|
await _hub.ResolveChallenge(77, true);
|
||||||
|
|
||||||
|
_groupProxy.Verify(p => p.SendCoreAsync("ChallengeResolved", It.IsAny<object?[]>(), default), Times.Once);
|
||||||
|
_clientProxy.Verify(p => p.SendCoreAsync("PlayerHandUpdated", It.IsAny<object?[]>(), default), Times.Exactly(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveChallenge_SendsError_WhenCallerIsNotTarget()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var target = TestDbHelper.SeedUser(db, "target", "target@t.local");
|
||||||
|
var notTarget = TestDbHelper.SeedUser(db, "not", "not@t.local");
|
||||||
|
_userId = notTarget.Id;
|
||||||
|
SetupContext();
|
||||||
|
var game = SeedGameWithPlayers((target, null));
|
||||||
|
var targetPlayer = db.Players.Single(p => p.UserId == target.Id);
|
||||||
|
using (var db2 = _factory.CreateDbContext())
|
||||||
|
{
|
||||||
|
db2.SlipChallenges.Add(new SlipChallenge
|
||||||
|
{
|
||||||
|
Id = 78,
|
||||||
|
GameRoundId = 1,
|
||||||
|
ChallengingPlayerId = 1,
|
||||||
|
TargetPlayerId = targetPlayer.Id,
|
||||||
|
TargetCardId = 1,
|
||||||
|
Status = ChallengeStatus.Pending
|
||||||
|
});
|
||||||
|
db2.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _hub.ResolveChallenge(78, true);
|
||||||
|
|
||||||
|
Assert.Equal("Only the accused player can resolve this challenge", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveChallenge_SendsMessage_WhenChallengeNotFound()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db);
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
|
||||||
|
await _hub.ResolveChallenge(999, true);
|
||||||
|
|
||||||
|
Assert.Equal("Challenge not found", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- RequestGameState ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestGameState_SendsStateToCaller_WhenParticipant()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db);
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
SeedGameWithPlayers((user, ConnectionId));
|
||||||
|
var gameId = db.Games.Single().Id;
|
||||||
|
|
||||||
|
_gameService.Setup(s => s.GetGameStateAsync(gameId)).ReturnsAsync(new GameStateDto { GameId = gameId });
|
||||||
|
|
||||||
|
await _hub.RequestGameState(gameId);
|
||||||
|
|
||||||
|
_callerProxy.Verify(p => p.SendCoreAsync("GameStateUpdated", It.IsAny<object?[]>(), default), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestGameState_SendsError_WhenNotParticipant()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db);
|
||||||
|
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
SeedGameWithPlayers((other, null));
|
||||||
|
var gameId = db.Games.Single().Id;
|
||||||
|
|
||||||
|
await _hub.RequestGameState(gameId);
|
||||||
|
|
||||||
|
Assert.Equal("User is not part of this game", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestGameState_SendsGenericError_WhenServiceThrows()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db);
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
SeedGameWithPlayers((user, ConnectionId));
|
||||||
|
var gameId = db.Games.Single().Id;
|
||||||
|
|
||||||
|
_gameService.Setup(s => s.GetGameStateAsync(It.IsAny<int>())).ThrowsAsync(new Exception("boom"));
|
||||||
|
|
||||||
|
await _hub.RequestGameState(gameId);
|
||||||
|
|
||||||
|
Assert.Equal("An error occurred while requesting game state.", ErrorMessageOf(_callerProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- OnConnectedAsync ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnConnectedAsync_StoresConnectionId_WhenUserInActiveGame()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db);
|
||||||
|
_userId = user.Id;
|
||||||
|
SetupContext();
|
||||||
|
SeedGameWithPlayers((user, null));
|
||||||
|
|
||||||
|
await _hub.OnConnectedAsync();
|
||||||
|
|
||||||
|
using var verify = _factory.CreateDbContext();
|
||||||
|
Assert.Equal(ConnectionId, verify.Players.Single(p => p.UserId == user.Id).ConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnConnectedAsync_DoesNotThrow_WhenNotAuthenticated()
|
||||||
|
{
|
||||||
|
WithoutAuth();
|
||||||
|
|
||||||
|
await _hub.OnConnectedAsync(); // darf nicht werfen
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- OnDisconnectedAsync ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnDisconnectedAsync_Completes()
|
||||||
|
{
|
||||||
|
await _hub.OnDisconnectedAsync(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
556
SlipItIn.Server.Tests/GameServiceTests.cs
Normal file
556
SlipItIn.Server.Tests/GameServiceTests.cs
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using SlipItIn.Server.Data;
|
||||||
|
using SlipItIn.Server.Services;
|
||||||
|
using SlipItIn.Shared.Models;
|
||||||
|
|
||||||
|
namespace SlipItIn.Server.Tests;
|
||||||
|
|
||||||
|
public class GameServiceTests
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<SlipItInDbContext> _factory = TestDbHelper.CreateFactory();
|
||||||
|
private readonly GameService _sut;
|
||||||
|
|
||||||
|
public GameServiceTests()
|
||||||
|
{
|
||||||
|
_sut = new GameService(_factory, NullLogger<GameService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- CreateGameAsync ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateGameAsync_CreatesGameWithHostAsFirstPlayer()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
|
||||||
|
var game = await _sut.CreateGameAsync(host.Id, "conn-1");
|
||||||
|
|
||||||
|
Assert.Equal(host.Id, game.HostId);
|
||||||
|
Assert.Equal(GameStatus.Lobby, game.Status);
|
||||||
|
Assert.Equal(6, game.LobbyCode.Length);
|
||||||
|
Assert.Single(game.Players);
|
||||||
|
Assert.Equal(host.Id, game.Players.First().UserId);
|
||||||
|
Assert.Equal("conn-1", game.Players.First().ConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateGameAsync_Throws_WhenHostNotFound()
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.CreateGameAsync(999));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- JoinGameAsync ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task JoinGameAsync_AddsPlayerToGame()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
|
||||||
|
var joiner = TestDbHelper.SeedUser(db, "joiner", "joiner@t.local");
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host);
|
||||||
|
|
||||||
|
var result = await _sut.JoinGameAsync(game.LobbyCode, joiner.Id, "conn-2");
|
||||||
|
|
||||||
|
Assert.Equal(game.Id, result.Id);
|
||||||
|
Assert.Equal(2, db.Players.Count(p => p.GameId == game.Id));
|
||||||
|
Assert.Equal("conn-2", db.Players.Single(p => p.UserId == joiner.Id).ConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task JoinGameAsync_Throws_WhenGameNotFound()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var user = TestDbHelper.SeedUser(db);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.JoinGameAsync("NOCODE", user.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task JoinGameAsync_Throws_WhenGameAlreadyStarted()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
|
||||||
|
var joiner = TestDbHelper.SeedUser(db, "joiner", "joiner@t.local");
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host, GameStatus.InProgress);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.JoinGameAsync(game.LobbyCode, joiner.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task JoinGameAsync_Throws_WhenGameIsFull()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host);
|
||||||
|
game.MaxPlayers = 1;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var joiner = TestDbHelper.SeedUser(db, "joiner", "joiner@t.local");
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.JoinGameAsync(game.LobbyCode, joiner.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task JoinGameAsync_Throws_WhenUserNotFound()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.JoinGameAsync(game.LobbyCode, 999));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task JoinGameAsync_Throws_WhenUserAlreadyInGame()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.JoinGameAsync(game.LobbyCode, host.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- StartGameAsync ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartGameAsync_SetsStatusAndCreatesFirstRound()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host);
|
||||||
|
db.Players.Add(new Player { UserId = other.Id, GameId = game.Id });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.StartGameAsync(game.Id);
|
||||||
|
|
||||||
|
Assert.Equal(GameStatus.InProgress, result.Status);
|
||||||
|
Assert.NotNull(result.StartedAt);
|
||||||
|
var round = db.GameRounds.Single(r => r.GameId == game.Id);
|
||||||
|
Assert.Equal(1, round.RoundNumber);
|
||||||
|
Assert.Equal(RoundStatus.Waiting, round.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartGameAsync_Throws_WhenGameNotFound()
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.StartGameAsync(999));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- SetPlayerReadyAsync ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetPlayerReadyAsync_MarksPlayerReady()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host);
|
||||||
|
|
||||||
|
var result = await _sut.SetPlayerReadyAsync(game.Id, hostPlayer.Id);
|
||||||
|
|
||||||
|
Assert.Equal(game.Id, result.Id);
|
||||||
|
using var verify = _factory.CreateDbContext();
|
||||||
|
Assert.True(verify.Players.Single(p => p.Id == hostPlayer.Id).IsReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetPlayerReadyAsync_Throws_WhenPlayerNotFound()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.SetPlayerReadyAsync(game.Id, 999));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- DealCardsAsync ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DealCardsAsync_DealsFiveCardsPerPlayerAndActivatesRound()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
|
||||||
|
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host);
|
||||||
|
db.Players.Add(new Player { UserId = other.Id, GameId = game.Id });
|
||||||
|
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Waiting };
|
||||||
|
db.GameRounds.Add(round);
|
||||||
|
TestDbHelper.SeedPhrases(db, 10);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var cards = await _sut.DealCardsAsync(game.Id);
|
||||||
|
|
||||||
|
Assert.Equal(10, cards.Count); // 2 Spieler x 5 Karten
|
||||||
|
Assert.Equal(5, cards.Count(c => c.PlayerId == game.Players.First().Id));
|
||||||
|
Assert.All(cards, c => Assert.False(c.IsUsed));
|
||||||
|
using var verify = _factory.CreateDbContext();
|
||||||
|
Assert.Equal(RoundStatus.Active, verify.GameRounds.Single(r => r.Id == round.Id).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DealCardsAsync_Throws_WhenNoWaitingRound()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host);
|
||||||
|
TestDbHelper.SeedPhrases(db, 10);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.DealCardsAsync(game.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DealCardsAsync_Throws_WhenNotEnoughPhrases()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host);
|
||||||
|
db.GameRounds.Add(new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Waiting });
|
||||||
|
TestDbHelper.SeedPhrases(db, 4);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.DealCardsAsync(game.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- SubmitSlipAsync ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubmitSlipAsync_MarksCardAsUsed()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host, GameStatus.InProgress);
|
||||||
|
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
|
||||||
|
db.GameRounds.Add(round);
|
||||||
|
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
|
||||||
|
var card = new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id };
|
||||||
|
db.PlayerCards.Add(card);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.SubmitSlipAsync(game.Id, hostPlayer.Id, card.Id);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
using var verify = _factory.CreateDbContext();
|
||||||
|
Assert.True(verify.PlayerCards.Single(c => c.Id == card.Id).IsUsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubmitSlipAsync_Throws_WhenCardNotFound()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host, GameStatus.InProgress);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.SubmitSlipAsync(game.Id, 1, 999));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubmitSlipAsync_Throws_WhenCardBelongsToOtherPlayer()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
|
||||||
|
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
|
||||||
|
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host, GameStatus.InProgress);
|
||||||
|
var otherPlayer = new Player { UserId = other.Id, GameId = game.Id };
|
||||||
|
db.Players.Add(otherPlayer);
|
||||||
|
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
|
||||||
|
db.GameRounds.Add(round);
|
||||||
|
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
|
||||||
|
var card = new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id };
|
||||||
|
db.PlayerCards.Add(card);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// otherPlayer versucht, die Karte des Hosts zu verwenden
|
||||||
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => _sut.SubmitSlipAsync(game.Id, otherPlayer.Id, card.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- CreateChallengeAsync ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChallengeAsync_CreatesPendingChallenge()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
|
||||||
|
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
|
||||||
|
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host);
|
||||||
|
var otherPlayer = new Player { UserId = other.Id, GameId = game.Id };
|
||||||
|
db.Players.Add(otherPlayer);
|
||||||
|
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
|
||||||
|
db.GameRounds.Add(round);
|
||||||
|
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
|
||||||
|
var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id, IsUsed = true };
|
||||||
|
db.PlayerCards.Add(card);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var challenge = await _sut.CreateChallengeAsync(game.Id, hostPlayer.Id, card.Id);
|
||||||
|
|
||||||
|
Assert.Equal(round.Id, challenge.GameRoundId);
|
||||||
|
Assert.Equal(hostPlayer.Id, challenge.ChallengingPlayerId);
|
||||||
|
Assert.Equal(otherPlayer.Id, challenge.TargetPlayerId);
|
||||||
|
Assert.Equal(card.Id, challenge.TargetCardId);
|
||||||
|
Assert.Equal(ChallengeStatus.Pending, challenge.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChallengeAsync_Throws_WhenTargetCardNotFound()
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.CreateChallengeAsync(1, 1, 999));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChallengeAsync_Throws_WhenNoActiveRound()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host);
|
||||||
|
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
|
||||||
|
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Waiting };
|
||||||
|
db.GameRounds.Add(round);
|
||||||
|
var card = new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id };
|
||||||
|
db.PlayerCards.Add(card);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.CreateChallengeAsync(game.Id, hostPlayer.Id, card.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChallengeAsync_Throws_WhenChallengerNotInGame()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
|
||||||
|
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
|
||||||
|
var outsider = TestDbHelper.SeedUser(db, "outsider", "outsider@t.local");
|
||||||
|
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host);
|
||||||
|
var otherPlayer = new Player { UserId = other.Id, GameId = game.Id };
|
||||||
|
db.Players.Add(otherPlayer);
|
||||||
|
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
|
||||||
|
db.GameRounds.Add(round);
|
||||||
|
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
|
||||||
|
var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id, IsUsed = true };
|
||||||
|
db.PlayerCards.Add(card);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// outsider ist nicht in diesem Spiel und versucht eine Challenge anzulegen
|
||||||
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||||
|
() => _sut.CreateChallengeAsync(game.Id, hostPlayer.Id + 1000, card.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChallengeAsync_Throws_WhenChallengerFromOtherGame()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var hostA = TestDbHelper.SeedUser(db, "hostA", "hostA@t.local");
|
||||||
|
var hostB = TestDbHelper.SeedUser(db, "hostB", "hostB@t.local");
|
||||||
|
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
|
||||||
|
var (gameA, hostPlayerA) = TestDbHelper.SeedGame(db, hostA);
|
||||||
|
var (gameB, _) = TestDbHelper.SeedGame(db, hostB);
|
||||||
|
var otherPlayerB = new Player { UserId = other.Id, GameId = gameB.Id };
|
||||||
|
db.Players.Add(otherPlayerB);
|
||||||
|
var roundB = new GameRound { GameId = gameB.Id, RoundNumber = 1, Status = RoundStatus.Active };
|
||||||
|
db.GameRounds.Add(roundB);
|
||||||
|
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
|
||||||
|
var cardB = new PlayerCard { PlayerId = otherPlayerB.Id, PhraseId = phrase.Id, GameRoundId = roundB.Id, IsUsed = true };
|
||||||
|
db.PlayerCards.Add(cardB);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// hostPlayerA gehört zu gameA, versucht aber eine Karte aus gameB zu challengen
|
||||||
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||||
|
() => _sut.CreateChallengeAsync(gameB.Id, hostPlayerA.Id, cardB.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- ResolveChallengeAsync ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveChallengeAsync_Approved_TransfersCardToChallenger()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
|
||||||
|
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
|
||||||
|
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host);
|
||||||
|
var otherPlayer = new Player { UserId = other.Id, GameId = game.Id };
|
||||||
|
db.Players.Add(otherPlayer);
|
||||||
|
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
|
||||||
|
db.GameRounds.Add(round);
|
||||||
|
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
|
||||||
|
var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id, IsUsed = true };
|
||||||
|
db.PlayerCards.Add(card);
|
||||||
|
var challenge = new SlipChallenge
|
||||||
|
{
|
||||||
|
GameRoundId = round.Id,
|
||||||
|
ChallengingPlayerId = hostPlayer.Id,
|
||||||
|
TargetPlayerId = otherPlayer.Id,
|
||||||
|
TargetCardId = card.Id,
|
||||||
|
Status = ChallengeStatus.Pending
|
||||||
|
};
|
||||||
|
db.SlipChallenges.Add(challenge);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.ResolveChallengeAsync(challenge.Id, approved: true);
|
||||||
|
|
||||||
|
Assert.Equal(ChallengeStatus.Approved, result.Status);
|
||||||
|
Assert.NotNull(result.ResolvedAt);
|
||||||
|
// Berechtigte Beschuldigung: Karte geht an den Beschuldiger
|
||||||
|
using var verify = _factory.CreateDbContext();
|
||||||
|
Assert.Equal(hostPlayer.Id, verify.PlayerCards.Single(c => c.Id == card.Id).PlayerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveChallengeAsync_Rejected_KeepsCardWithTargetPlayer()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db, "host", "host@t.local");
|
||||||
|
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
|
||||||
|
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host);
|
||||||
|
var otherPlayer = new Player { UserId = other.Id, GameId = game.Id };
|
||||||
|
db.Players.Add(otherPlayer);
|
||||||
|
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
|
||||||
|
db.GameRounds.Add(round);
|
||||||
|
var phrase = TestDbHelper.SeedPhrases(db, 1).Single();
|
||||||
|
var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id, IsUsed = true };
|
||||||
|
db.PlayerCards.Add(card);
|
||||||
|
var challenge = new SlipChallenge
|
||||||
|
{
|
||||||
|
GameRoundId = round.Id,
|
||||||
|
ChallengingPlayerId = hostPlayer.Id,
|
||||||
|
TargetPlayerId = otherPlayer.Id,
|
||||||
|
TargetCardId = card.Id,
|
||||||
|
Status = ChallengeStatus.Pending
|
||||||
|
};
|
||||||
|
db.SlipChallenges.Add(challenge);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _sut.ResolveChallengeAsync(challenge.Id, approved: false);
|
||||||
|
|
||||||
|
Assert.Equal(ChallengeStatus.Rejected, result.Status);
|
||||||
|
Assert.NotNull(result.ResolvedAt);
|
||||||
|
// Falsche Beschuldigung: Karte bleibt beim Beschuldigten
|
||||||
|
using var verify = _factory.CreateDbContext();
|
||||||
|
Assert.Equal(otherPlayer.Id, verify.PlayerCards.Single(c => c.Id == card.Id).PlayerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveChallengeAsync_Throws_WhenChallengeNotFound()
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.ResolveChallengeAsync(999, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- GetGameStateAsync ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetGameStateAsync_ReturnsStateWithoutRound_WhenNoRoundsExist()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host);
|
||||||
|
|
||||||
|
var state = await _sut.GetGameStateAsync(game.Id);
|
||||||
|
|
||||||
|
Assert.Equal(game.Id, state.GameId);
|
||||||
|
Assert.Equal(game.LobbyCode, state.LobbyCode);
|
||||||
|
Assert.Equal("Lobby", state.Status);
|
||||||
|
Assert.Single(state.Players);
|
||||||
|
Assert.Equal(host.Username, state.Players[0].Username);
|
||||||
|
Assert.Equal(0, state.CurrentRound);
|
||||||
|
Assert.Equal(45, state.RoundTimeRemaining); // Default RoundDurationSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetGameStateAsync_ComputesRemainingTime_WhenRoundActive()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host);
|
||||||
|
game.RoundDurationSeconds = 45;
|
||||||
|
db.GameRounds.Add(new GameRound
|
||||||
|
{
|
||||||
|
GameId = game.Id,
|
||||||
|
RoundNumber = 2,
|
||||||
|
Status = RoundStatus.Active,
|
||||||
|
StartedAt = DateTime.UtcNow.AddSeconds(-20)
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var state = await _sut.GetGameStateAsync(game.Id);
|
||||||
|
|
||||||
|
Assert.Equal(2, state.CurrentRound);
|
||||||
|
Assert.InRange(state.RoundTimeRemaining, 24, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetGameStateAsync_ClampsRemainingTimeToZero_WhenRoundExpired()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host);
|
||||||
|
db.GameRounds.Add(new GameRound
|
||||||
|
{
|
||||||
|
GameId = game.Id,
|
||||||
|
RoundNumber = 1,
|
||||||
|
Status = RoundStatus.Active,
|
||||||
|
StartedAt = DateTime.UtcNow.AddSeconds(-120)
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var state = await _sut.GetGameStateAsync(game.Id);
|
||||||
|
|
||||||
|
Assert.Equal(0, state.RoundTimeRemaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetGameStateAsync_Throws_WhenGameNotFound()
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.GetGameStateAsync(999));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- GetPlayerHandAsync ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlayerHandAsync_ReturnsCardsWithPhraseText()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host);
|
||||||
|
var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active };
|
||||||
|
db.GameRounds.Add(round);
|
||||||
|
var phrases = TestDbHelper.SeedPhrases(db, 2);
|
||||||
|
db.PlayerCards.Add(new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrases[0].Id, GameRoundId = round.Id });
|
||||||
|
db.PlayerCards.Add(new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrases[1].Id, GameRoundId = round.Id, IsUsed = true });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var hand = await _sut.GetPlayerHandAsync(hostPlayer.Id);
|
||||||
|
|
||||||
|
Assert.Equal(hostPlayer.Id, hand.PlayerId);
|
||||||
|
Assert.Equal(2, hand.Cards.Count);
|
||||||
|
Assert.Contains(hand.Cards, c => c.Text == "Phrase 1" && !c.IsUsed);
|
||||||
|
Assert.Contains(hand.Cards, c => c.Text == "Phrase 2" && c.IsUsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlayerHandAsync_ReturnsEmptyHand_WhenNoCards()
|
||||||
|
{
|
||||||
|
var hand = await _sut.GetPlayerHandAsync(999);
|
||||||
|
|
||||||
|
Assert.Equal(999, hand.PlayerId);
|
||||||
|
Assert.Empty(hand.Cards);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- GetGameAsync ----------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetGameAsync_ReturnsGame()
|
||||||
|
{
|
||||||
|
using var db = _factory.CreateDbContext();
|
||||||
|
var host = TestDbHelper.SeedUser(db);
|
||||||
|
var (game, _) = TestDbHelper.SeedGame(db, host);
|
||||||
|
|
||||||
|
var result = await _sut.GetGameAsync(game.Id);
|
||||||
|
|
||||||
|
Assert.Equal(game.Id, result.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetGameAsync_Throws_WhenGameNotFound()
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.GetGameAsync(999));
|
||||||
|
}
|
||||||
|
}
|
||||||
33
SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj
Normal file
33
SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="10.0.1">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\SlipItIn.Server\SlipItIn.Server.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
56
SlipItIn.Server.Tests/TestDbHelper.cs
Normal file
56
SlipItIn.Server.Tests/TestDbHelper.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SlipItIn.Server.Data;
|
||||||
|
using SlipItIn.Shared.Models;
|
||||||
|
|
||||||
|
namespace SlipItIn.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hilfsmethoden zum Erstellen isolierter InMemory-DbContexts und Testdaten.
|
||||||
|
/// </summary>
|
||||||
|
public static class TestDbHelper
|
||||||
|
{
|
||||||
|
public static IDbContextFactory<SlipItInDbContext> CreateFactory()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<SlipItInDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new TestDbContextFactory(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static User SeedUser(SlipItInDbContext db, string username = "user", string email = "user@test.local", bool isActive = true)
|
||||||
|
{
|
||||||
|
var user = new User { Username = username, Email = email, PasswordHash = "hash", IsActive = isActive };
|
||||||
|
db.Users.Add(user);
|
||||||
|
db.SaveChanges();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Phrase> SeedPhrases(SlipItInDbContext db, int count, bool isActive = true)
|
||||||
|
{
|
||||||
|
var creator = SeedUser(db, "creator-" + Guid.NewGuid().ToString("N")[..8], Guid.NewGuid().ToString("N")[..8] + "@t.local");
|
||||||
|
var phrases = Enumerable.Range(1, count)
|
||||||
|
.Select(i => new Phrase { Text = $"Phrase {i}", CreatorId = creator.Id, IsActive = isActive })
|
||||||
|
.ToList();
|
||||||
|
db.Phrases.AddRange(phrases);
|
||||||
|
db.SaveChanges();
|
||||||
|
return phrases;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Erstellt ein Spiel inkl. Host-Spieler über den echten GameService-Pfad (direkt in DB).</summary>
|
||||||
|
public static (Game game, Player hostPlayer) SeedGame(SlipItInDbContext db, User host, GameStatus status = GameStatus.Lobby)
|
||||||
|
{
|
||||||
|
var game = new Game { LobbyCode = Guid.NewGuid().ToString("N")[..6].ToUpper(), HostId = host.Id, Status = status };
|
||||||
|
var hostPlayer = new Player { UserId = host.Id, Game = game };
|
||||||
|
game.Players.Add(hostPlayer);
|
||||||
|
db.Games.Add(game);
|
||||||
|
db.SaveChanges();
|
||||||
|
return (game, hostPlayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestDbContextFactory(DbContextOptions<SlipItInDbContext> options) : IDbContextFactory<SlipItInDbContext>
|
||||||
|
{
|
||||||
|
public SlipItInDbContext CreateDbContext() => new(options);
|
||||||
|
public Task<SlipItInDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(new SlipItInDbContext(options));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,11 +17,13 @@ public class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly IDbContextFactory<SlipItInDbContext> _contextFactory;
|
private readonly IDbContextFactory<SlipItInDbContext> _contextFactory;
|
||||||
private readonly IConfiguration _configuration;
|
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;
|
_contextFactory = contextFactory;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
|
_environment = environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
@@ -30,6 +32,9 @@ public class AuthController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
return BadRequest("Username, Email und Passwort sind erforderlich.");
|
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();
|
using var context = _contextFactory.CreateDbContext();
|
||||||
|
|
||||||
var normalizedEmail = request.Email.Trim().ToLowerInvariant();
|
var normalizedEmail = request.Email.Trim().ToLowerInvariant();
|
||||||
@@ -86,7 +91,7 @@ public class AuthController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
UserId = int.Parse(userIdClaim),
|
UserId = int.TryParse(userIdClaim, out var parsedUserId) ? parsedUserId : 0,
|
||||||
Username = username,
|
Username = username,
|
||||||
Email = email
|
Email = email
|
||||||
});
|
});
|
||||||
@@ -94,7 +99,13 @@ public class AuthController : ControllerBase
|
|||||||
|
|
||||||
private AuthResponseDto CreateAuthResponse(User user)
|
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 jwtIssuer = _configuration["Jwt:Issuer"] ?? "SlipItInServer";
|
||||||
var jwtAudience = _configuration["Jwt:Audience"] ?? "SlipItInClient";
|
var jwtAudience = _configuration["Jwt:Audience"] ?? "SlipItInClient";
|
||||||
var expirationMinutes = int.TryParse(_configuration["Jwt:ExpirationMinutes"], out var parsedMinutes) ? parsedMinutes : 1440;
|
var expirationMinutes = int.TryParse(_configuration["Jwt:ExpirationMinutes"], out var parsedMinutes) ? parsedMinutes : 1440;
|
||||||
|
|||||||
89
SlipItIn.Server/Data/DbSeeder.cs
Normal file
89
SlipItIn.Server/Data/DbSeeder.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SlipItIn.Shared.Models;
|
||||||
|
|
||||||
|
namespace SlipItIn.Server.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Befüllt die Datenbank mit Testdaten (2 Testuser, einige Dutzend Phrasen),
|
||||||
|
/// wenn sie leer ist. Dient der Erleichterung manueller Tests.
|
||||||
|
/// </summary>
|
||||||
|
public static class DbSeeder
|
||||||
|
{
|
||||||
|
public static async Task SeedAsync(SlipItInDbContext context)
|
||||||
|
{
|
||||||
|
// Nur seeden, wenn die Datenbank leer ist
|
||||||
|
if (await context.Users.AnyAsync() || await context.Phrases.AnyAsync())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var alice = new User
|
||||||
|
{
|
||||||
|
Username = "alice",
|
||||||
|
Email = "alice@test.local",
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("Test123!")
|
||||||
|
};
|
||||||
|
|
||||||
|
var bob = new User
|
||||||
|
{
|
||||||
|
Username = "bob",
|
||||||
|
Email = "bob@test.local",
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("Test123!")
|
||||||
|
};
|
||||||
|
|
||||||
|
context.Users.AddRange(alice, bob);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var phrases = new[]
|
||||||
|
{
|
||||||
|
"Das hat mir meine Oma schon immer gesagt.",
|
||||||
|
"Ich hab da mal eine Doku gesehen.",
|
||||||
|
"Das ist wie damals in der Grundschule.",
|
||||||
|
"Das muss man sich mal auf der Zunge zergehen lassen.",
|
||||||
|
"Da kommt mir direkt ein Lied in den Sinn.",
|
||||||
|
"Das ist wie beim Autofahren: Bremsen kann man immer noch.",
|
||||||
|
"Ich habe das Gefühl, wir reden aneinander vorbei.",
|
||||||
|
"Das würde ich an deiner Stelle nochmal überdenken.",
|
||||||
|
"Ich glaube, da steckt mehr dahinter.",
|
||||||
|
"Also ich finde, das riecht verdächtig nach Montag.",
|
||||||
|
"Das schmeckt nach mehr.",
|
||||||
|
"Da ist der Wurm drin.",
|
||||||
|
"Das hat hand und Fuß.",
|
||||||
|
"Das ist ein zweischneidiges Schwert.",
|
||||||
|
"Da liegt der Hase im Pfeffer.",
|
||||||
|
"Das ist wie ein guter Wein – wird mit den Jahren besser.",
|
||||||
|
"Ich würde sagen: Glück im Unglück.",
|
||||||
|
"Das ist schon fast eine Wissenschaft für sich.",
|
||||||
|
"Man muss das Kind beim Namen nennen.",
|
||||||
|
"Das ist wie Weihnachten und Geburtstag zusammen.",
|
||||||
|
"Da geht mir das Herz auf.",
|
||||||
|
"Ich würde das jetzt nicht überbewerten.",
|
||||||
|
"Das kommt mir spanisch vor.",
|
||||||
|
"Das ist das Beste seit geschnittenem Brot.",
|
||||||
|
"Da bleibt kein Auge trocken.",
|
||||||
|
"Das ist wie Nägel mit Köpfen machen.",
|
||||||
|
"Ich habe da so eine Vermutung.",
|
||||||
|
"Das ist wie ein roter Faden.",
|
||||||
|
"Da platzt mir gleich der Kragen.",
|
||||||
|
"Ich bin da ganz bei dir.",
|
||||||
|
"Das muss man erstmal sacken lassen.",
|
||||||
|
"Das ist wie ein offenes Buch.",
|
||||||
|
"Da würde ich nicht drauf wetten.",
|
||||||
|
"Das ist wie eine Achterbahn der Gefühle.",
|
||||||
|
"Ich hab da ein ganz mieses Gefühl bei.",
|
||||||
|
"Das ist wie ein Dorn im Auge."
|
||||||
|
};
|
||||||
|
|
||||||
|
var random = new Random(42);
|
||||||
|
var creators = new[] { alice, bob };
|
||||||
|
|
||||||
|
foreach (var text in phrases)
|
||||||
|
{
|
||||||
|
context.Phrases.Add(new Phrase
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
CreatorId = creators[random.Next(creators.Length)].Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,6 +142,7 @@ public class GameHub : Hub
|
|||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
|
// Geprüfte Fachfehler mit festen, harmonsierten Texten dürfen an den Client.
|
||||||
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -259,10 +260,16 @@ public class GameHub : Hub
|
|||||||
var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetCardId);
|
var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetCardId);
|
||||||
var game = await _gameService.GetGameAsync(gameId);
|
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
|
var challengeDto = new SlipChallengeDto
|
||||||
{
|
{
|
||||||
ChallengeId = challenge.Id,
|
ChallengeId = challenge.Id,
|
||||||
ChallengingPlayerId = challengingPlayerId,
|
ChallengingPlayerId = challengingPlayerId,
|
||||||
|
ChallengingPlayerName = challengingPlayer?.User.Username ?? string.Empty,
|
||||||
TargetPlayerId = challenge.TargetPlayerId,
|
TargetPlayerId = challenge.TargetPlayerId,
|
||||||
TargetCardId = targetCardId,
|
TargetCardId = targetCardId,
|
||||||
Status = challenge.Status.ToString(),
|
Status = challenge.Status.ToString(),
|
||||||
@@ -272,7 +279,6 @@ public class GameHub : Hub
|
|||||||
await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto);
|
await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto);
|
||||||
|
|
||||||
// Sende direkte Notification an den beschuldigten Spieler
|
// Sende direkte Notification an den beschuldigten Spieler
|
||||||
using var context = _contextFactory.CreateDbContext();
|
|
||||||
var targetPlayer = await context.Players.FindAsync(challenge.TargetPlayerId);
|
var targetPlayer = await context.Players.FindAsync(challenge.TargetPlayerId);
|
||||||
if (targetPlayer?.ConnectionId != null)
|
if (targetPlayer?.ConnectionId != null)
|
||||||
{
|
{
|
||||||
@@ -372,9 +378,26 @@ public class GameHub : Hub
|
|||||||
{
|
{
|
||||||
player.ConnectionId = Context.ConnectionId;
|
player.ConnectionId = Context.ConnectionId;
|
||||||
await context.SaveChangesAsync();
|
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();
|
await base.OnConnectedAsync();
|
||||||
}
|
}
|
||||||
@@ -382,6 +405,23 @@ public class GameHub : Hub
|
|||||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Client {ConnectionId} disconnected", Context.ConnectionId);
|
_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);
|
await base.OnDisconnectedAsync(exception);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
using SlipItIn.Server.Data;
|
using SlipItIn.Server.Data;
|
||||||
using SlipItIn.Server.Hubs;
|
using SlipItIn.Server.Hubs;
|
||||||
using SlipItIn.Server.Services;
|
using SlipItIn.Server.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -13,13 +14,27 @@ builder.AddServiceDefaults();
|
|||||||
|
|
||||||
builder.AddNpgsqlDataSource(connectionName: "postgresdb");
|
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
|
// Services registrieren
|
||||||
builder.Services.AddTransient<IGameService, GameService>();
|
builder.Services.AddTransient<IGameService, GameService>();
|
||||||
|
|
||||||
// JWT Authentication
|
// 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 jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "SlipItInServer";
|
||||||
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "SlipItInClient";
|
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "SlipItInClient";
|
||||||
|
|
||||||
@@ -50,6 +65,24 @@ builder.Services
|
|||||||
context.Token = accessToken;
|
context.Token = accessToken;
|
||||||
}
|
}
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnTokenValidated = async context =>
|
||||||
|
{
|
||||||
|
var userIdClaim = context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (!int.TryParse(userIdClaim, out var userId))
|
||||||
|
{
|
||||||
|
context.Fail("Invalid user claim.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbFactory = context.HttpContext.RequestServices.GetRequiredService<IDbContextFactory<SlipItInDbContext>>();
|
||||||
|
await using var db = await dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var userExists = await db.Users.AnyAsync(u => u.Id == userId && u.IsActive);
|
||||||
|
if (!userExists)
|
||||||
|
{
|
||||||
|
context.Fail("User no longer exists or is inactive.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -79,21 +112,28 @@ var app = builder.Build();
|
|||||||
// Aspire Default Endpoints (Health Checks)
|
// Aspire Default Endpoints (Health Checks)
|
||||||
app.MapDefaultEndpoints();
|
app.MapDefaultEndpoints();
|
||||||
|
|
||||||
// Migrations anwenden
|
// Migrations anwenden und Testdaten seeden (nur wenn die Datenbank leer ist)
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<SlipItInDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<SlipItInDbContext>();
|
||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
|
await DbSeeder.SeedAsync(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
app.UseCors("AllowAll");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseCors("AllowAll");
|
||||||
|
|
||||||
|
// Beim Emulator/geräteübergreifenden Zugriff würde die HTTPS-Umleitung
|
||||||
|
// den Client auf eine nicht vertrauenswürdige Dev-Zertifikats-URL schicken.
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
}
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using SlipItIn.Server.Data;
|
using SlipItIn.Server.Data;
|
||||||
using SlipItIn.Shared.DTOs;
|
using SlipItIn.Shared.DTOs;
|
||||||
using SlipItIn.Shared.Models;
|
using SlipItIn.Shared.Models;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace SlipItIn.Server.Services;
|
namespace SlipItIn.Server.Services;
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ public class GameService : IGameService
|
|||||||
using var context = _contextFactory.CreateDbContext();
|
using var context = _contextFactory.CreateDbContext();
|
||||||
|
|
||||||
var host = await context.Users.FirstOrDefaultAsync(u => u.Id == hostUserId) ?? throw new InvalidOperationException("Host not found");
|
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
|
var game = new Game
|
||||||
{
|
{
|
||||||
LobbyCode = lobbyCode,
|
LobbyCode = lobbyCode,
|
||||||
@@ -70,7 +71,16 @@ public class GameService : IGameService
|
|||||||
{
|
{
|
||||||
using var context = _contextFactory.CreateDbContext();
|
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.Status = GameStatus.InProgress;
|
||||||
game.StartedAt = DateTime.UtcNow;
|
game.StartedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
@@ -85,12 +95,16 @@ public class GameService : IGameService
|
|||||||
{
|
{
|
||||||
using var context = _contextFactory.CreateDbContext();
|
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;
|
player.IsReady = true;
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
return await context.Games.FirstOrDefaultAsync(g => g.Id == gameId)
|
return game;
|
||||||
?? throw new InvalidOperationException("Game not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<PlayerCard>> DealCardsAsync(int gameId)
|
public async Task<List<PlayerCard>> DealCardsAsync(int gameId)
|
||||||
@@ -108,7 +122,7 @@ public class GameService : IGameService
|
|||||||
|
|
||||||
foreach (var player in players)
|
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)
|
foreach (var phrase in selectedPhrases)
|
||||||
{
|
{
|
||||||
@@ -125,6 +139,7 @@ public class GameService : IGameService
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentRound.Status = RoundStatus.Active;
|
currentRound.Status = RoundStatus.Active;
|
||||||
|
currentRound.StartedAt = DateTime.UtcNow;
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
return dealtCards;
|
return dealtCards;
|
||||||
@@ -134,10 +149,18 @@ public class GameService : IGameService
|
|||||||
{
|
{
|
||||||
using var context = _contextFactory.CreateDbContext();
|
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
|
var card = await context.PlayerCards
|
||||||
.Include(pc => pc.Player)
|
.Include(pc => pc.GameRound)
|
||||||
.FirstOrDefaultAsync(pc => pc.Id == cardId) ?? throw new InvalidOperationException("Card not found");
|
.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.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;
|
card.IsUsed = true;
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
@@ -149,13 +172,29 @@ public class GameService : IGameService
|
|||||||
{
|
{
|
||||||
using var context = _contextFactory.CreateDbContext();
|
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
|
var currentRound = await context.GameRounds
|
||||||
.Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Active)
|
.Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Active)
|
||||||
.FirstOrDefaultAsync() ?? throw new InvalidOperationException("No active round found");
|
.FirstOrDefaultAsync() ?? throw new InvalidOperationException("No active round found");
|
||||||
|
|
||||||
if (currentRound == null) throw new InvalidOperationException("No active round found");
|
var challengerBelongsToGame = await context.Players
|
||||||
|
.AnyAsync(p => p.Id == challengingPlayerId && p.GameId == gameId);
|
||||||
|
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");
|
||||||
|
|
||||||
|
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
|
var challenge = new SlipChallenge
|
||||||
{
|
{
|
||||||
@@ -179,19 +218,30 @@ public class GameService : IGameService
|
|||||||
var challenge = await context.SlipChallenges
|
var challenge = await context.SlipChallenges
|
||||||
.Include(sc => sc.GameRound)
|
.Include(sc => sc.GameRound)
|
||||||
.Include(sc => sc.TargetCard)
|
.Include(sc => sc.TargetCard)
|
||||||
|
.Include(sc => sc.ChallengingPlayer)
|
||||||
|
.Include(sc => sc.TargetPlayer)
|
||||||
.FirstOrDefaultAsync(sc => sc.Id == challengeId) ?? throw new InvalidOperationException("Challenge not found");
|
.FirstOrDefaultAsync(sc => sc.Id == challengeId) ?? throw new InvalidOperationException("Challenge not found");
|
||||||
|
|
||||||
// approved = Beschuldigung stimmt (Phrase war falsch)
|
if (challenge.Status != ChallengeStatus.Pending)
|
||||||
// rejected = Beschuldigung stimmt nicht (Phrase war richtig)
|
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
|
// Berechtigte Beschuldigung: Beschuldigter muss die Phrase an den Beschuldiger abgeben
|
||||||
var targetCard = challenge.TargetCard;
|
challenge.TargetCard.PlayerId = challenge.ChallengingPlayerId;
|
||||||
targetCard.PlayerId = challenge.ChallengingPlayerId;
|
challenge.ChallengingPlayer.Score += 1;
|
||||||
context.PlayerCards.Update(targetCard);
|
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.Status = approved ? ChallengeStatus.Approved : ChallengeStatus.Rejected;
|
||||||
challenge.ResolvedAt = DateTime.UtcNow;
|
challenge.ResolvedAt = DateTime.UtcNow;
|
||||||
@@ -218,7 +268,7 @@ public class GameService : IGameService
|
|||||||
Username = p.User.Username,
|
Username = p.User.Username,
|
||||||
Score = p.Score,
|
Score = p.Score,
|
||||||
IsReady = p.IsReady,
|
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();
|
}).ToList();
|
||||||
|
|
||||||
var currentRound = game.Rounds
|
var currentRound = game.Rounds
|
||||||
@@ -249,9 +299,17 @@ public class GameService : IGameService
|
|||||||
{
|
{
|
||||||
using var context = _contextFactory.CreateDbContext();
|
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
|
var cards = await context.PlayerCards
|
||||||
.Include(pc => pc.Phrase)
|
.Include(pc => pc.Phrase)
|
||||||
.Where(pc => pc.PlayerId == playerId)
|
.Where(pc => pc.PlayerId == playerId && activeRoundIds.Contains(pc.GameRoundId))
|
||||||
.Select(pc => new PlayerCardDto
|
.Select(pc => new PlayerCardDto
|
||||||
{
|
{
|
||||||
CardId = pc.Id,
|
CardId = pc.Id,
|
||||||
@@ -275,11 +333,25 @@ public class GameService : IGameService
|
|||||||
?? throw new InvalidOperationException("Game not found");
|
?? throw new InvalidOperationException("Game not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GenerateLobbyCode()
|
private static string GenerateLobbyCode()
|
||||||
{
|
{
|
||||||
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Aspire.Npgsql" Version="13.4.6" />
|
<PackageReference Include="Aspire.Npgsql" Version="13.5.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.11" />
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
|
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -4,5 +4,8 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Key": "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
|
||||||
"postgresdb": "Server=(localdb)\\postgres;Database=postgresdb"
|
|
||||||
},
|
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
|
|
||||||
"Issuer": "SlipItInServer",
|
"Issuer": "SlipItInServer",
|
||||||
"Audience": "SlipItInClient",
|
"Audience": "SlipItInClient",
|
||||||
"ExpirationMinutes": 1440
|
"ExpirationMinutes": 1440
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.8.0" />
|
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.9.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.8.0" />
|
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.9.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.18.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/Projektmappenelemente/">
|
<Project Path="SlipItIn.AppHost/SlipItIn.AppHost.csproj" />
|
||||||
<File Path="Agents/Architecture.md" />
|
<Project Path="SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj" />
|
||||||
<File Path="Agents/ProjectPlan.md" />
|
|
||||||
</Folder>
|
|
||||||
<Project Path="SlipItIN.AppHost/SlipItIn.AppHost.csproj" />
|
|
||||||
<Project Path="SlipItIn.Server/SlipItIn.Server.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.Shared/SlipItIn.Shared.csproj" />
|
||||||
<Project Path="SlipItIN/SlipItIn.csproj">
|
<Project Path="SlipItIn/SlipItIn.csproj">
|
||||||
<Deploy Solution="Debug|*" />
|
<Deploy Solution="Debug|*" />
|
||||||
</Project>
|
</Project>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -5,37 +5,82 @@ namespace SlipItIn;
|
|||||||
|
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
|
private readonly SemaphoreSlim _startupLock = new(1, 1);
|
||||||
|
|
||||||
public App()
|
public App()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_ = InitializeAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override Window CreateWindow(IActivationState? activationState)
|
protected override Window CreateWindow(IActivationState? activationState)
|
||||||
{
|
{
|
||||||
var window = new Window(new AppShell());
|
var window = new Window(new AppShell());
|
||||||
|
|
||||||
|
window.Created += async (_, _) =>
|
||||||
|
{
|
||||||
|
await InitializeSafeAsync();
|
||||||
|
};
|
||||||
|
|
||||||
window.Resumed += async (_, _) =>
|
window.Resumed += async (_, _) =>
|
||||||
{
|
{
|
||||||
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
|
try
|
||||||
await gameStateService.ResyncAsync();
|
{
|
||||||
|
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
|
||||||
|
await gameStateService.ResyncAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Resume darf nicht abstürzen; Sync-Status wurde bereits gemeldet.
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task InitializeAsync()
|
private async Task InitializeSafeAsync()
|
||||||
{
|
{
|
||||||
var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>();
|
if (!await _startupLock.WaitAsync(0))
|
||||||
var signalR = ServiceHelper.GetRequiredService<ISignalRService>();
|
return;
|
||||||
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
|
|
||||||
|
|
||||||
await authSession.InitializeAsync();
|
try
|
||||||
await gameStateService.InitializeAsync();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(authSession.AccessToken))
|
|
||||||
{
|
{
|
||||||
|
var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>();
|
||||||
|
var signalR = ServiceHelper.GetRequiredService<ISignalRService>();
|
||||||
|
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
|
||||||
|
var apiService = ServiceHelper.GetRequiredService<IApiService>();
|
||||||
|
|
||||||
|
await authSession.InitializeAsync();
|
||||||
|
await gameStateService.InitializeAsync();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(authSession.AccessToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
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();
|
||||||
|
await signalR.DisconnectAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var connected = await signalR.ConnectAsync(authSession.AccessToken);
|
var connected = await signalR.ConnectAsync(authSession.AccessToken);
|
||||||
if (connected)
|
if (connected)
|
||||||
await gameStateService.ResyncAsync();
|
await gameStateService.ResyncAsync();
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Start darf nicht abstürzen, wenn Session/Netzwerk fehlschlägt.
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_startupLock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,12 @@
|
|||||||
x:Class="SlipItIn.AppShell"
|
x:Class="SlipItIn.AppShell"
|
||||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||||
xmlns:views="clr-namespace:SlipItIn.Views">
|
xmlns:views="clr-namespace:SlipItIn.Views"
|
||||||
|
FlyoutBehavior="Disabled">
|
||||||
|
|
||||||
<ShellContent
|
<ShellContent
|
||||||
Route="LoginPage"
|
Title="Login"
|
||||||
|
Route="LoginPage"
|
||||||
ContentTemplate="{DataTemplate views:LoginPage}" />
|
ContentTemplate="{DataTemplate views:LoginPage}" />
|
||||||
|
|
||||||
<ShellContent
|
|
||||||
Route="RegisterPage"
|
|
||||||
ContentTemplate="{DataTemplate views:RegisterPage}" />
|
|
||||||
|
|
||||||
<ShellContent
|
|
||||||
Route="LobbyPage"
|
|
||||||
ContentTemplate="{DataTemplate views:LobbyPage}" />
|
|
||||||
|
|
||||||
<ShellContent
|
|
||||||
Route="GameBoardPage"
|
|
||||||
ContentTemplate="{DataTemplate views:GameBoardPage}" />
|
|
||||||
|
|
||||||
</Shell>
|
</Shell>
|
||||||
|
|||||||
@@ -7,5 +7,8 @@ public partial class AppShell : Shell
|
|||||||
public AppShell()
|
public AppShell()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
Routing.RegisterRoute(nameof(RegisterPage), typeof(RegisterPage));
|
||||||
|
Routing.RegisterRoute(nameof(LobbyPage), typeof(LobbyPage));
|
||||||
|
Routing.RegisterRoute(nameof(GameBoardPage), typeof(GameBoardPage));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
SlipItIn/Messages/SyncStateChangedMessage.cs
Normal file
12
SlipItIn/Messages/SyncStateChangedMessage.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||||
|
|
||||||
|
namespace SlipItIn.Messages;
|
||||||
|
|
||||||
|
public sealed record SyncStateInfo(bool IsSyncing, string StatusText, int PendingActions);
|
||||||
|
|
||||||
|
public class SyncStateChangedMessage : ValueChangedMessage<SyncStateInfo>
|
||||||
|
{
|
||||||
|
public SyncStateChangedMessage(SyncStateInfo value) : base(value)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,4 +5,5 @@ public class QueuedGameAction
|
|||||||
public string Method { get; set; } = string.Empty;
|
public string Method { get; set; } = string.Empty;
|
||||||
public string ArgumentsJson { get; set; } = "[]";
|
public string ArgumentsJson { get; set; } = "[]";
|
||||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
public int RetryCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
11
SlipItIn/Platforms/Android/AndroidManifest.Debug.xml
Normal file
11
SlipItIn/Platforms/Android/AndroidManifest.Debug.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.krampitz.slipitin">
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/appicon"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:label="Slip It In"
|
||||||
|
android:usesCleartextTraffic="true" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
11
SlipItIn/Platforms/Android/AndroidManifest.Release.xml
Normal file
11
SlipItIn/Platforms/Android/AndroidManifest.Release.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.krampitz.slipitin">
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/appicon"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:label="Slip It In">
|
||||||
|
</application>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.krampitz.slipitin">
|
|
||||||
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:supportsRtl="true" android:label="Slip It In"></application>
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
</manifest>
|
|
||||||
@@ -28,6 +28,29 @@ public class ApiService : IApiService
|
|||||||
public Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default)
|
public Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default)
|
||||||
=> PostAuthAsync("api/auth/login", request, cancellationToken);
|
=> PostAuthAsync("api/auth/login", request, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<SessionValidationResult> ValidateSessionAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_authSession.AccessToken))
|
||||||
|
return SessionValidationResult.Invalid;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AttachAuthHeader();
|
||||||
|
var response = await _httpClient.GetAsync("api/auth/me", cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode
|
||||||
|
? SessionValidationResult.Valid
|
||||||
|
: SessionValidationResult.Invalid;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
return SessionValidationResult.NetworkError;
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
return SessionValidationResult.NetworkError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Task LogoutAsync() => _authSession.ClearSessionAsync();
|
public Task LogoutAsync() => _authSession.ClearSessionAsync();
|
||||||
|
|
||||||
private async Task<AuthResponseDto> PostAuthAsync<TRequest>(string endpoint, TRequest request, CancellationToken cancellationToken)
|
private async Task<AuthResponseDto> PostAuthAsync<TRequest>(string endpoint, TRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -4,9 +4,20 @@ namespace SlipItIn.Services;
|
|||||||
|
|
||||||
public class AppConfigurationService : IAppConfigurationService
|
public class AppConfigurationService : IAppConfigurationService
|
||||||
{
|
{
|
||||||
|
private const string ApiBaseUrlPreferenceKey = "api_base_url";
|
||||||
private const string DefaultApiBaseUrl = "https://localhost:7274";
|
private const string DefaultApiBaseUrl = "https://localhost:7274";
|
||||||
|
private const string AndroidEmulatorApiBaseUrl = "http://10.0.2.2:5234";
|
||||||
|
|
||||||
public string ApiBaseUrl => DefaultApiBaseUrl;
|
public string ApiBaseUrl => Preferences.Default
|
||||||
|
.Get(ApiBaseUrlPreferenceKey, GetPlatformDefaultApiBaseUrl())
|
||||||
|
.TrimEnd('/');
|
||||||
|
|
||||||
public string HubUrl => $"{DefaultApiBaseUrl.TrimEnd('/')}/hubs/game";
|
public string HubUrl => $"{ApiBaseUrl}/hubs/game";
|
||||||
|
|
||||||
|
private static string GetPlatformDefaultApiBaseUrl()
|
||||||
|
{
|
||||||
|
return DeviceInfo.Platform == DevicePlatform.Android
|
||||||
|
? AndroidEmulatorApiBaseUrl
|
||||||
|
: DefaultApiBaseUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public class GameStateService : IGameStateService
|
|||||||
private readonly ISignalRService _signalR;
|
private readonly ISignalRService _signalR;
|
||||||
private readonly ILocalStorageService _localStorage;
|
private readonly ILocalStorageService _localStorage;
|
||||||
private readonly IMessenger _messenger;
|
private readonly IMessenger _messenger;
|
||||||
|
private readonly SemaphoreSlim _syncLock = new(1, 1);
|
||||||
|
|
||||||
private readonly List<QueuedGameAction> _queuedActions = [];
|
private readonly List<QueuedGameAction> _queuedActions = [];
|
||||||
|
|
||||||
@@ -27,21 +28,21 @@ public class GameStateService : IGameStateService
|
|||||||
|
|
||||||
_signalR.LobbyCreated += (_, value) => _messenger.Send(new LobbyCreatedMessage(value));
|
_signalR.LobbyCreated += (_, value) => _messenger.Send(new LobbyCreatedMessage(value));
|
||||||
|
|
||||||
_signalR.GameStateUpdated += async (_, state) =>
|
_signalR.GameStateUpdated += (_, state) => RunSafeBridge(async () =>
|
||||||
{
|
{
|
||||||
CurrentGameState = state;
|
CurrentGameState = state;
|
||||||
await _localStorage.SaveGameStateAsync(state);
|
await _localStorage.SaveGameStateAsync(state);
|
||||||
_messenger.Send(new GameStateChangedMessage(state));
|
_messenger.Send(new GameStateChangedMessage(state));
|
||||||
};
|
});
|
||||||
|
|
||||||
_signalR.GameStarted += (_, gameId) => _messenger.Send(new GameStartedMessage(gameId));
|
_signalR.GameStarted += (_, gameId) => _messenger.Send(new GameStartedMessage(gameId));
|
||||||
|
|
||||||
_signalR.PlayerHandUpdated += async (_, hand) =>
|
_signalR.PlayerHandUpdated += (_, hand) => RunSafeBridge(async () =>
|
||||||
{
|
{
|
||||||
CurrentPlayerHand = hand;
|
CurrentPlayerHand = hand;
|
||||||
await _localStorage.SavePlayerHandAsync(hand);
|
await _localStorage.SavePlayerHandAsync(hand);
|
||||||
_messenger.Send(new PlayerHandChangedMessage(hand));
|
_messenger.Send(new PlayerHandChangedMessage(hand));
|
||||||
};
|
});
|
||||||
|
|
||||||
_signalR.ChallengeReceived += (_, challenge) =>
|
_signalR.ChallengeReceived += (_, challenge) =>
|
||||||
{
|
{
|
||||||
@@ -50,7 +51,26 @@ public class GameStateService : IGameStateService
|
|||||||
};
|
};
|
||||||
|
|
||||||
_signalR.ErrorReceived += (_, error) => _messenger.Send(new ErrorOccurredMessage(error));
|
_signalR.ErrorReceived += (_, error) => _messenger.Send(new ErrorOccurredMessage(error));
|
||||||
_signalR.ConnectionStateChanged += (_, connected) => _messenger.Send(new ConnectionStateChangedMessage(connected));
|
_signalR.ConnectionStateChanged += (_, connected) => RunSafeBridge(async () =>
|
||||||
|
{
|
||||||
|
_messenger.Send(new ConnectionStateChangedMessage(connected));
|
||||||
|
if (!connected)
|
||||||
|
return;
|
||||||
|
|
||||||
|
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()
|
public async Task InitializeAsync()
|
||||||
@@ -67,14 +87,32 @@ public class GameStateService : IGameStateService
|
|||||||
|
|
||||||
if (CurrentPlayerHand is not null)
|
if (CurrentPlayerHand is not null)
|
||||||
_messenger.Send(new PlayerHandChangedMessage(CurrentPlayerHand));
|
_messenger.Send(new PlayerHandChangedMessage(CurrentPlayerHand));
|
||||||
|
|
||||||
|
PublishSyncState(false, "Bereit", _queuedActions.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ResyncAsync()
|
public async Task ResyncAsync()
|
||||||
{
|
{
|
||||||
if (CurrentGameState?.GameId > 0 && _signalR.IsConnected)
|
await _syncLock.WaitAsync();
|
||||||
await _signalR.RequestGameStateAsync(CurrentGameState.GameId);
|
try
|
||||||
|
{
|
||||||
|
PublishSyncState(true, "Syncing...", _queuedActions.Count);
|
||||||
|
|
||||||
await FlushQueuedActionsAsync();
|
if (CurrentGameState?.GameId > 0 && _signalR.IsConnected)
|
||||||
|
await _signalR.RequestGameStateAsync(CurrentGameState.GameId);
|
||||||
|
|
||||||
|
await FlushQueuedActionsCoreAsync();
|
||||||
|
PublishSyncState(false, "Synchronisiert", _queuedActions.Count);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
PublishSyncState(false, "Sync fehlgeschlagen", _queuedActions.Count);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_syncLock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task EnqueueActionAsync(string method, params object[] arguments)
|
public async Task EnqueueActionAsync(string method, params object[] arguments)
|
||||||
@@ -88,21 +126,22 @@ public class GameStateService : IGameStateService
|
|||||||
|
|
||||||
_queuedActions.Add(action);
|
_queuedActions.Add(action);
|
||||||
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
|
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
|
||||||
|
PublishSyncState(false, "Offline-Aktion gespeichert", _queuedActions.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task FlushQueuedActionsAsync()
|
public async Task FlushQueuedActionsAsync()
|
||||||
{
|
{
|
||||||
if (!_signalR.IsConnected || _queuedActions.Count == 0)
|
await _syncLock.WaitAsync();
|
||||||
return;
|
try
|
||||||
|
|
||||||
var snapshot = _queuedActions.ToList();
|
|
||||||
foreach (var action in snapshot)
|
|
||||||
{
|
{
|
||||||
await _signalR.SendQueuedActionAsync(action);
|
PublishSyncState(true, "Warteschlange wird gesendet...", _queuedActions.Count);
|
||||||
_queuedActions.Remove(action);
|
await FlushQueuedActionsCoreAsync();
|
||||||
|
PublishSyncState(false, "Warteschlange synchronisiert", _queuedActions.Count);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_syncLock.Release();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ClearLocalGameDataAsync()
|
public async Task ClearLocalGameDataAsync()
|
||||||
@@ -115,5 +154,52 @@ public class GameStateService : IGameStateService
|
|||||||
await _localStorage.ClearGameStateAsync();
|
await _localStorage.ClearGameStateAsync();
|
||||||
await _localStorage.ClearPlayerHandAsync();
|
await _localStorage.ClearPlayerHandAsync();
|
||||||
await _localStorage.ClearQueuedActionsAsync();
|
await _localStorage.ClearQueuedActionsAsync();
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
await _signalR.SendQueuedActionAsync(action);
|
||||||
|
_queuedActions.Remove(action);
|
||||||
|
PublishSyncState(true, "Warteschlange wird gesendet...", _queuedActions.Count);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PublishSyncState(bool isSyncing, string statusText, int pendingActions)
|
||||||
|
{
|
||||||
|
_messenger.Send(new SyncStateChangedMessage(new SyncStateInfo(isSyncing, statusText, pendingActions)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,17 @@ using SlipItIn.Shared.DTOs;
|
|||||||
|
|
||||||
namespace SlipItIn.Services.Interfaces;
|
namespace SlipItIn.Services.Interfaces;
|
||||||
|
|
||||||
|
public enum SessionValidationResult
|
||||||
|
{
|
||||||
|
Valid,
|
||||||
|
Invalid,
|
||||||
|
NetworkError
|
||||||
|
}
|
||||||
|
|
||||||
public interface IApiService
|
public interface IApiService
|
||||||
{
|
{
|
||||||
Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
|
Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
|
||||||
Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
|
Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
|
||||||
|
Task<SessionValidationResult> ValidateSessionAsync(CancellationToken cancellationToken = default);
|
||||||
Task LogoutAsync();
|
Task LogoutAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using SlipItIn.Models;
|
using SlipItIn.Models;
|
||||||
using SlipItIn.Services.Interfaces;
|
using SlipItIn.Services.Interfaces;
|
||||||
using SlipItIn.Shared.DTOs;
|
using SlipItIn.Shared.DTOs;
|
||||||
|
|
||||||
namespace SlipItIn.Services;
|
namespace SlipItIn.Services;
|
||||||
|
|
||||||
public class SignalRService : ISignalRService
|
public class SignalRService : ISignalRService, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly IAppConfigurationService _configuration;
|
private readonly IAppConfigurationService _configuration;
|
||||||
private readonly IAuthSessionService _authSession;
|
private readonly IAuthSessionService _authSession;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly SemaphoreSlim _connectLock = new(1, 1);
|
||||||
private HubConnection? _hubConnection;
|
private HubConnection? _hubConnection;
|
||||||
|
|
||||||
public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession)
|
public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession, IServiceProvider serviceProvider)
|
||||||
{
|
{
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_authSession = authSession;
|
_authSession = authSession;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected;
|
public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected;
|
||||||
@@ -33,31 +37,76 @@ public class SignalRService : ISignalRService
|
|||||||
if (IsConnected)
|
if (IsConnected)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
EnsureConnection(token);
|
await _connectLock.WaitAsync(cancellationToken);
|
||||||
|
|
||||||
if (_hubConnection is null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _hubConnection.StartAsync(cancellationToken);
|
if (IsConnected)
|
||||||
ConnectionStateChanged?.Invoke(this, true);
|
return true;
|
||||||
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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
finally
|
||||||
{
|
{
|
||||||
ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}");
|
_connectLock.Release();
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DisconnectAsync()
|
public async Task DisconnectAsync()
|
||||||
{
|
{
|
||||||
if (_hubConnection is null)
|
await _connectLock.WaitAsync();
|
||||||
return;
|
try
|
||||||
|
{
|
||||||
|
if (_hubConnection is null)
|
||||||
|
return;
|
||||||
|
|
||||||
await _hubConnection.StopAsync();
|
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
|
||||||
ConnectionStateChanged?.Invoke(this, false);
|
await _hubConnection.StopAsync();
|
||||||
|
|
||||||
|
await _hubConnection.DisposeAsync();
|
||||||
|
_hubConnection = null;
|
||||||
|
|
||||||
|
ConnectionStateChanged?.Invoke(this, false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_connectLock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task CreateLobbyAsync() => InvokeAsync("CreateLobby");
|
public Task CreateLobbyAsync() => InvokeAsync("CreateLobby");
|
||||||
@@ -82,7 +131,7 @@ public class SignalRService : ISignalRService
|
|||||||
public async Task SendQueuedActionAsync(QueuedGameAction action)
|
public async Task SendQueuedActionAsync(QueuedGameAction action)
|
||||||
{
|
{
|
||||||
if (_hubConnection is null)
|
if (_hubConnection is null)
|
||||||
return;
|
throw new InvalidOperationException("Keine aktive SignalR-Verbindung.");
|
||||||
|
|
||||||
object?[] args;
|
object?[] args;
|
||||||
try
|
try
|
||||||
@@ -90,11 +139,11 @@ public class SignalRService : ISignalRService
|
|||||||
var doc = JsonDocument.Parse(action.ArgumentsJson);
|
var doc = JsonDocument.Parse(action.ArgumentsJson);
|
||||||
args = doc.RootElement.ValueKind == JsonValueKind.Array
|
args = doc.RootElement.ValueKind == JsonValueKind.Array
|
||||||
? doc.RootElement.EnumerateArray().Select(ToObject).ToArray()
|
? 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);
|
await InvokeAsync(action.Method, args);
|
||||||
@@ -108,11 +157,18 @@ public class SignalRService : ISignalRService
|
|||||||
_hubConnection = new HubConnectionBuilder()
|
_hubConnection = new HubConnectionBuilder()
|
||||||
.WithUrl(_configuration.HubUrl, options =>
|
.WithUrl(_configuration.HubUrl, options =>
|
||||||
{
|
{
|
||||||
options.AccessTokenProvider = () => Task.FromResult<string?>(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)])
|
.WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)])
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
|
_hubConnection.Reconnecting += _ =>
|
||||||
|
{
|
||||||
|
ConnectionStateChanged?.Invoke(this, false);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
_hubConnection.Closed += _ =>
|
_hubConnection.Closed += _ =>
|
||||||
{
|
{
|
||||||
ConnectionStateChanged?.Invoke(this, false);
|
ConnectionStateChanged?.Invoke(this, false);
|
||||||
@@ -144,6 +200,13 @@ public class SignalRService : ISignalRService
|
|||||||
_hubConnection.On<PlayerHandDto>("PlayerHandUpdated", dto => PlayerHandUpdated?.Invoke(this, dto));
|
_hubConnection.On<PlayerHandDto>("PlayerHandUpdated", dto => PlayerHandUpdated?.Invoke(this, dto));
|
||||||
_hubConnection.On<SlipChallengeDto>("ChallengeReceived", dto => ChallengeReceived?.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 =>
|
_hubConnection.On<JsonElement>("Error", payload =>
|
||||||
{
|
{
|
||||||
var message = payload.TryGetProperty("message", out var msgElement)
|
var message = payload.TryGetProperty("message", out var msgElement)
|
||||||
@@ -153,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)
|
private async Task InvokeAsync(string method, params object?[] args)
|
||||||
{
|
{
|
||||||
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
|
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
|
||||||
@@ -175,4 +258,10 @@ public class SignalRService : ISignalRService
|
|||||||
_ => element.GetRawText()
|
_ => element.GetRawText()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_hubConnection is not null)
|
||||||
|
await _hubConnection.DisposeAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFrameworks>net10.0-android</TargetFrameworks>
|
<TargetFrameworks>net10.0-android</TargetFrameworks>
|
||||||
@@ -48,35 +48,11 @@
|
|||||||
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
|
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-android|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
|
||||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-ios|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)'=='Release'">
|
||||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-maccatalyst|AnyCPU'">
|
|
||||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-windows10.0.19041.0|AnyCPU'">
|
|
||||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
|
|
||||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-ios|AnyCPU'">
|
|
||||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-maccatalyst|AnyCPU'">
|
|
||||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-windows10.0.19041.0|AnyCPU'">
|
|
||||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -89,7 +65,6 @@
|
|||||||
|
|
||||||
<!-- Images -->
|
<!-- Images -->
|
||||||
<MauiImage Include="Resources\Images\*" />
|
<MauiImage Include="Resources\Images\*" />
|
||||||
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
|
|
||||||
|
|
||||||
<!-- Custom Fonts -->
|
<!-- Custom Fonts -->
|
||||||
<MauiFont Include="Resources\Fonts\*" />
|
<MauiFont Include="Resources\Fonts\*" />
|
||||||
@@ -99,10 +74,10 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.90" />
|
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.100" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="11.0.0-preview.6.26359.118" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="11.0.0-preview.6.26359.118" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.10" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -121,4 +96,12 @@
|
|||||||
</MauiXaml>
|
</MauiXaml>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)'=='Debug|net10.0-android'">
|
||||||
|
<AndroidManifest>Platforms\Android\AndroidManifest.Debug.xml</AndroidManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)'=='Release|net10.0-android'">
|
||||||
|
<AndroidManifest>Platforms\Android\AndroidManifest.Release.xml</AndroidManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ public partial class GameBoardViewModel : BaseViewModel,
|
|||||||
IRecipient<GameStateChangedMessage>,
|
IRecipient<GameStateChangedMessage>,
|
||||||
IRecipient<PlayerHandChangedMessage>,
|
IRecipient<PlayerHandChangedMessage>,
|
||||||
IRecipient<ChallengeReceivedMessage>,
|
IRecipient<ChallengeReceivedMessage>,
|
||||||
IRecipient<ErrorOccurredMessage>
|
IRecipient<ErrorOccurredMessage>,
|
||||||
|
IRecipient<SyncStateChangedMessage>,
|
||||||
|
IRecipient<ConnectionStateChangedMessage>
|
||||||
{
|
{
|
||||||
private readonly ISignalRService _signalR;
|
private readonly ISignalRService _signalR;
|
||||||
private readonly IGameStateService _gameStateService;
|
private readonly IGameStateService _gameStateService;
|
||||||
private readonly IAuthSessionService _authSession;
|
private readonly IAuthSessionService _authSession;
|
||||||
|
private readonly IDispatcherTimer? _roundTimer;
|
||||||
|
private DateTime _lastStateUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private int gameId;
|
private int gameId;
|
||||||
@@ -33,6 +37,18 @@ public partial class GameBoardViewModel : BaseViewModel,
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private SlipChallengeDto? pendingChallenge;
|
private SlipChallengeDto? pendingChallenge;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isConnected;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isSyncing;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string syncStatusText = "Bereit";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int pendingSyncActions;
|
||||||
|
|
||||||
public ObservableCollection<PlayerCardDto> MyCards { get; } = [];
|
public ObservableCollection<PlayerCardDto> MyCards { get; } = [];
|
||||||
public ObservableCollection<PlayerInfoDto> OtherPlayers { get; } = [];
|
public ObservableCollection<PlayerInfoDto> OtherPlayers { get; } = [];
|
||||||
|
|
||||||
@@ -42,8 +58,28 @@ public partial class GameBoardViewModel : BaseViewModel,
|
|||||||
_gameStateService = gameStateService;
|
_gameStateService = gameStateService;
|
||||||
_authSession = authSession;
|
_authSession = authSession;
|
||||||
|
|
||||||
WeakReferenceMessenger.Default.RegisterAll(this);
|
IsConnected = _signalR.IsConnected;
|
||||||
IsActive = true;
|
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]
|
[RelayCommand]
|
||||||
@@ -109,9 +145,22 @@ public partial class GameBoardViewModel : BaseViewModel,
|
|||||||
if (PendingChallenge is null)
|
if (PendingChallenge is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, true);
|
var challengeId = PendingChallenge.ChallengeId;
|
||||||
PendingChallenge = null;
|
PendingChallenge = null;
|
||||||
IsChallengeVisible = false;
|
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]
|
[RelayCommand]
|
||||||
@@ -120,9 +169,22 @@ public partial class GameBoardViewModel : BaseViewModel,
|
|||||||
if (PendingChallenge is null)
|
if (PendingChallenge is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, false);
|
var challengeId = PendingChallenge.ChallengeId;
|
||||||
PendingChallenge = null;
|
PendingChallenge = null;
|
||||||
IsChallengeVisible = false;
|
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)
|
public void Receive(GameStateChangedMessage message)
|
||||||
@@ -132,6 +194,7 @@ public partial class GameBoardViewModel : BaseViewModel,
|
|||||||
GameId = message.Value.GameId;
|
GameId = message.Value.GameId;
|
||||||
CurrentRound = message.Value.CurrentRound;
|
CurrentRound = message.Value.CurrentRound;
|
||||||
RoundTimeRemaining = message.Value.RoundTimeRemaining;
|
RoundTimeRemaining = message.Value.RoundTimeRemaining;
|
||||||
|
_lastStateUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
var me = _authSession.CurrentUser?.Username;
|
var me = _authSession.CurrentUser?.Username;
|
||||||
var others = message.Value.Players
|
var others = message.Value.Players
|
||||||
@@ -156,15 +219,36 @@ public partial class GameBoardViewModel : BaseViewModel,
|
|||||||
|
|
||||||
public void Receive(ChallengeReceivedMessage message)
|
public void Receive(ChallengeReceivedMessage message)
|
||||||
{
|
{
|
||||||
PendingChallenge = message.Value;
|
MainThread.BeginInvokeOnMainThread(() =>
|
||||||
IsChallengeVisible = true;
|
{
|
||||||
|
PendingChallenge = message.Value;
|
||||||
|
IsChallengeVisible = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Receive(ErrorOccurredMessage message)
|
public void Receive(ErrorOccurredMessage message)
|
||||||
{
|
{
|
||||||
StatusMessage = message.Value;
|
MainThread.BeginInvokeOnMainThread(() => StatusMessage = message.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Receive(SyncStateChangedMessage message)
|
||||||
|
{
|
||||||
|
MainThread.BeginInvokeOnMainThread(() =>
|
||||||
|
{
|
||||||
|
IsSyncing = message.Value.IsSyncing;
|
||||||
|
SyncStatusText = message.Value.StatusText;
|
||||||
|
PendingSyncActions = message.Value.PendingActions;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Receive(ConnectionStateChangedMessage message)
|
||||||
|
{
|
||||||
|
MainThread.BeginInvokeOnMainThread(() => IsConnected = message.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StartRoundTimer() => _roundTimer?.Start();
|
||||||
|
public void StopRoundTimer() => _roundTimer?.Stop();
|
||||||
|
|
||||||
private int GetCurrentPlayerId()
|
private int GetCurrentPlayerId()
|
||||||
{
|
{
|
||||||
var username = _authSession.CurrentUser?.Username;
|
var username = _authSession.CurrentUser?.Username;
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ public partial class LobbyViewModel : BaseViewModel,
|
|||||||
IRecipient<LobbyCreatedMessage>,
|
IRecipient<LobbyCreatedMessage>,
|
||||||
IRecipient<GameStartedMessage>,
|
IRecipient<GameStartedMessage>,
|
||||||
IRecipient<ErrorOccurredMessage>,
|
IRecipient<ErrorOccurredMessage>,
|
||||||
IRecipient<ConnectionStateChangedMessage>
|
IRecipient<ConnectionStateChangedMessage>,
|
||||||
|
IRecipient<SyncStateChangedMessage>
|
||||||
{
|
{
|
||||||
private readonly ISignalRService _signalR;
|
private readonly ISignalRService _signalR;
|
||||||
private readonly IGameStateService _gameStateService;
|
private readonly IGameStateService _gameStateService;
|
||||||
@@ -32,6 +33,15 @@ public partial class LobbyViewModel : BaseViewModel,
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool isConnected;
|
private bool isConnected;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isSyncing;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string syncStatusText = "Bereit";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int pendingSyncActions;
|
||||||
|
|
||||||
public ObservableCollection<PlayerInfoDto> Players { get; } = [];
|
public ObservableCollection<PlayerInfoDto> Players { get; } = [];
|
||||||
|
|
||||||
public LobbyViewModel(ISignalRService signalR, IGameStateService gameStateService, IAuthSessionService authSession, IApiService apiService)
|
public LobbyViewModel(ISignalRService signalR, IGameStateService gameStateService, IAuthSessionService authSession, IApiService apiService)
|
||||||
@@ -42,7 +52,16 @@ public partial class LobbyViewModel : BaseViewModel,
|
|||||||
_apiService = apiService;
|
_apiService = apiService;
|
||||||
|
|
||||||
IsConnected = _signalR.IsConnected;
|
IsConnected = _signalR.IsConnected;
|
||||||
//WeakReferenceMessenger.Default.RegisterAll(this);
|
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;
|
IsActive = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +115,7 @@ public partial class LobbyViewModel : BaseViewModel,
|
|||||||
{
|
{
|
||||||
await RunSafeAsync(async () =>
|
await RunSafeAsync(async () =>
|
||||||
{
|
{
|
||||||
|
IsActive = false;
|
||||||
await _signalR.DisconnectAsync();
|
await _signalR.DisconnectAsync();
|
||||||
await _apiService.LogoutAsync();
|
await _apiService.LogoutAsync();
|
||||||
await _gameStateService.ClearLocalGameDataAsync();
|
await _gameStateService.ClearLocalGameDataAsync();
|
||||||
@@ -125,19 +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)
|
public void Receive(ErrorOccurredMessage message)
|
||||||
{
|
{
|
||||||
StatusMessage = message.Value;
|
MainThread.BeginInvokeOnMainThread(() => StatusMessage = message.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Receive(ConnectionStateChangedMessage message)
|
public void Receive(ConnectionStateChangedMessage message)
|
||||||
{
|
{
|
||||||
IsConnected = message.Value;
|
MainThread.BeginInvokeOnMainThread(() => IsConnected = message.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Receive(SyncStateChangedMessage message)
|
||||||
|
{
|
||||||
|
MainThread.BeginInvokeOnMainThread(() =>
|
||||||
|
{
|
||||||
|
IsSyncing = message.Value.IsSyncing;
|
||||||
|
SyncStatusText = message.Value.StatusText;
|
||||||
|
PendingSyncActions = message.Value.PendingActions;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private int GetCurrentPlayerId()
|
private int GetCurrentPlayerId()
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ public partial class LoginViewModel : BaseViewModel
|
|||||||
private readonly IGameStateService _gameStateService;
|
private readonly IGameStateService _gameStateService;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Email { get; set; }
|
private string email = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Password { get; set; }
|
private string password = string.Empty;
|
||||||
|
|
||||||
public LoginViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR, IGameStateService gameStateService)
|
public LoginViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR, IGameStateService gameStateService)
|
||||||
{
|
{
|
||||||
@@ -28,7 +28,7 @@ public partial class LoginViewModel : BaseViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private Task GoToRegisterAsync() => Shell.Current.GoToAsync($"//{nameof(RegisterPage)}");
|
private Task GoToRegisterAsync() => Shell.Current.GoToAsync(nameof(RegisterPage));
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task LoginAsync()
|
private async Task LoginAsync()
|
||||||
@@ -42,20 +42,40 @@ public partial class LoginViewModel : BaseViewModel
|
|||||||
});
|
});
|
||||||
|
|
||||||
await _authSession.SetSessionAsync(response);
|
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 _gameStateService.InitializeAsync();
|
await _gameStateService.InitializeAsync();
|
||||||
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}");
|
await Shell.Current.GoToAsync(nameof(LobbyPage));
|
||||||
}, "Anmeldung läuft...");
|
}, "Anmeldung läuft...");
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
bool isAuthenticated = _authSession.IsAuthenticated;
|
if (!_authSession.IsAuthenticated)
|
||||||
|
return;
|
||||||
|
|
||||||
if ( isAuthenticated)
|
var sessionResult = await _apiService.ValidateSessionAsync();
|
||||||
|
if (sessionResult == SessionValidationResult.NetworkError)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (sessionResult == SessionValidationResult.Invalid)
|
||||||
{
|
{
|
||||||
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}");
|
await _authSession.ClearSessionAsync();
|
||||||
|
await _gameStateService.ClearLocalGameDataAsync();
|
||||||
|
await _signalR.DisconnectAsync();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var token = _authSession.AccessToken;
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var connected = await _signalR.ConnectAsync(token);
|
||||||
|
if (connected)
|
||||||
|
await Shell.Current.GoToAsync(nameof(LobbyPage));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ public partial class RegisterViewModel : BaseViewModel
|
|||||||
private readonly ISignalRService _signalR;
|
private readonly ISignalRService _signalR;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Username { get; set; } = string.Empty;
|
private string username = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Email { get; set; } = string.Empty;
|
private string email = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Password { get; set; } = string.Empty;
|
private string password = string.Empty;
|
||||||
|
|
||||||
public RegisterViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR)
|
public RegisterViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR)
|
||||||
{
|
{
|
||||||
@@ -44,8 +44,10 @@ public partial class RegisterViewModel : BaseViewModel
|
|||||||
});
|
});
|
||||||
|
|
||||||
await _authSession.SetSessionAsync(response);
|
await _authSession.SetSessionAsync(response);
|
||||||
await _signalR.ConnectAsync(response.Token);
|
var connected = await _signalR.ConnectAsync(response.Token);
|
||||||
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}");
|
if (!connected)
|
||||||
|
throw new InvalidOperationException("Verbindung zum Spielserver fehlgeschlagen.");
|
||||||
|
await Shell.Current.GoToAsync(nameof(LobbyPage));
|
||||||
}, "Registrierung läuft...");
|
}, "Registrierung läuft...");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8" ?>
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
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">
|
<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">
|
<VerticalStackLayout Spacing="10">
|
||||||
<Label Text="Du wurdest beschuldigt" FontAttributes="Bold" />
|
<Label Text="Du wurdest beschuldigt" FontAttributes="Bold" />
|
||||||
<Label Text="{Binding PendingChallenge.ChallengeId, StringFormat='ChallengeId: {0}'}" />
|
<Label Text="{Binding PendingChallenge.ChallengeId, StringFormat='ChallengeId: {0}'}" />
|
||||||
@@ -12,6 +14,6 @@
|
|||||||
<Button Text="Ablehnen" Command="{Binding RejectChallengeCommand}" />
|
<Button Text="Ablehnen" Command="{Binding RejectChallengeCommand}" />
|
||||||
</HorizontalStackLayout>
|
</HorizontalStackLayout>
|
||||||
</VerticalStackLayout>
|
</VerticalStackLayout>
|
||||||
</Frame>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</ContentView>
|
</ContentView>
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
<Label Text="Spielbrett" FontSize="24" FontAttributes="Bold" />
|
<Label Text="Spielbrett" FontSize="24" FontAttributes="Bold" />
|
||||||
<Label Text="{Binding CurrentRound, StringFormat='Runde: {0}'}" />
|
<Label Text="{Binding CurrentRound, StringFormat='Runde: {0}'}" />
|
||||||
<Label Text="{Binding RoundTimeRemaining, StringFormat='Verbleibende Zeit: {0}s'}" />
|
<Label Text="{Binding RoundTimeRemaining, StringFormat='Verbleibende Zeit: {0}s'}" />
|
||||||
|
<Label Text="{Binding IsConnected, StringFormat='Verbindung: {0}'}" TextColor="Gray" />
|
||||||
|
<Label Text="{Binding SyncStatusText}" TextColor="DodgerBlue" IsVisible="{Binding IsSyncing}" />
|
||||||
|
<Label Text="{Binding PendingSyncActions, StringFormat='Offline-Aktionen: {0}'}" TextColor="Gray" />
|
||||||
|
|
||||||
<Button Text="Status aktualisieren" Command="{Binding RefreshStateCommand}" />
|
<Button Text="Status aktualisieren" Command="{Binding RefreshStateCommand}" />
|
||||||
|
|
||||||
@@ -17,7 +20,7 @@
|
|||||||
<CollectionView ItemsSource="{Binding MyCards}">
|
<CollectionView ItemsSource="{Binding MyCards}">
|
||||||
<CollectionView.ItemTemplate>
|
<CollectionView.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Frame Margin="0,4" Padding="10">
|
<Border Margin="0,4" Padding="10" Stroke="LightGray" StrokeThickness="1" StrokeShape="RoundRectangle 10" BackgroundColor="White">
|
||||||
<VerticalStackLayout>
|
<VerticalStackLayout>
|
||||||
<Label Text="{Binding Text}" />
|
<Label Text="{Binding Text}" />
|
||||||
<HorizontalStackLayout>
|
<HorizontalStackLayout>
|
||||||
@@ -25,7 +28,7 @@
|
|||||||
<Button Text="Beschuldigen" Command="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.ChallengeCardCommand}" CommandParameter="{Binding .}" />
|
<Button Text="Beschuldigen" Command="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.ChallengeCardCommand}" CommandParameter="{Binding .}" />
|
||||||
</HorizontalStackLayout>
|
</HorizontalStackLayout>
|
||||||
</VerticalStackLayout>
|
</VerticalStackLayout>
|
||||||
</Frame>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</CollectionView.ItemTemplate>
|
</CollectionView.ItemTemplate>
|
||||||
</CollectionView>
|
</CollectionView>
|
||||||
|
|||||||
@@ -5,9 +5,23 @@ namespace SlipItIn.Views;
|
|||||||
|
|
||||||
public partial class GameBoardPage : ContentPage
|
public partial class GameBoardPage : ContentPage
|
||||||
{
|
{
|
||||||
|
private readonly GameBoardViewModel _viewModel;
|
||||||
|
|
||||||
public GameBoardPage()
|
public GameBoardPage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
<VerticalStackLayout Padding="24" Spacing="12">
|
<VerticalStackLayout Padding="24" Spacing="12">
|
||||||
<Label Text="Lobby" FontSize="24" FontAttributes="Bold" />
|
<Label Text="Lobby" FontSize="24" FontAttributes="Bold" />
|
||||||
<Label Text="{Binding IsConnected, StringFormat='Verbindung: {0}'}" />
|
<Label Text="{Binding IsConnected, StringFormat='Verbindung: {0}'}" />
|
||||||
|
<Label Text="{Binding SyncStatusText}" TextColor="DodgerBlue" IsVisible="{Binding IsSyncing}" />
|
||||||
|
<Label Text="{Binding PendingSyncActions, StringFormat='Offline-Aktionen: {0}'}" TextColor="Gray" />
|
||||||
|
|
||||||
<Button Text="Lobby erstellen" Command="{Binding CreateLobbyCommand}" />
|
<Button Text="Lobby erstellen" Command="{Binding CreateLobbyCommand}" />
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ namespace SlipItIn.Views;
|
|||||||
|
|
||||||
public partial class LobbyPage : ContentPage
|
public partial class LobbyPage : ContentPage
|
||||||
{
|
{
|
||||||
|
private readonly LobbyViewModel _viewModel;
|
||||||
|
|
||||||
public LobbyPage()
|
public LobbyPage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
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": {
|
"appHost": {
|
||||||
"path": "SlipItIN.AppHost/SlipItIN.AppHost.csproj"
|
"path": "SlipItIn.AppHost/SlipItIn.AppHost.csproj"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user