Compare commits
23 Commits
e9675158a3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 118660a207 | |||
|
|
7953b8ddd0 | ||
|
|
660fc4d311 | ||
|
|
5e22f0ab9b | ||
|
|
af6528ee68 | ||
|
|
7d4344c062 | ||
|
|
510cc0e520 | ||
|
|
b1435b2c1e | ||
|
|
fe1319a34c | ||
|
|
dede059983 | ||
|
|
8078ac2212 | ||
|
|
540cda102f | ||
| bf306a6c15 | |||
|
|
3296ecdfb3 | ||
|
|
6f3c0eb716 | ||
|
|
0f9cc1703a | ||
|
|
936c8bde28 | ||
|
|
2ce9bcd4c4 | ||
|
|
c67c549f48 | ||
| 37616a9316 | |||
|
|
18a9d1ab74 | ||
|
|
a5db6dc26e | ||
|
|
118a62a804 |
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
|
||||
30
.github/workflows/openwiki-update.yml
vendored
30
.github/workflows/openwiki-update.yml
vendored
@@ -14,16 +14,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
with:
|
||||
# Full history so `openwiki code --update` can diff HEAD against the
|
||||
# commit it last documented; a shallow clone hides that commit and the
|
||||
# update runs against an empty change summary.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install OpenWiki
|
||||
# mermaid + jsdom are optional; they add high-fidelity validation of Mermaid diagrams. Remove if your wiki has none.
|
||||
run: npm install --global openwiki@0.2.3 mermaid@11.16.0 jsdom@29.1.1
|
||||
run: npm install --global openwiki mermaid@11.16.0 jsdom@29.1.1
|
||||
|
||||
- name: Run OpenWiki
|
||||
run: openwiki code --update --print
|
||||
@@ -32,8 +37,19 @@ jobs:
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
OPENWIKI_MODEL_ID: gemini-3.6-flash
|
||||
|
||||
|
||||
- name: Restore protected workflow file
|
||||
# `openwiki code --update` regenerates this file from its internal
|
||||
# template on every run, which silently drops the guard above (see
|
||||
# https://github.com/langchain-ai/openwiki/issues/423). Until that's
|
||||
# fixed upstream, discard whatever the agent just wrote here so the
|
||||
# guard always survives, regardless of whether the run above
|
||||
# succeeded or failed.
|
||||
if: always()
|
||||
run: git checkout -- .github/workflows/openwiki-update.yml
|
||||
|
||||
- name: Create OpenWiki update pull request
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
uses: infinilabs/gitea-pr@v1
|
||||
with:
|
||||
add-paths: |
|
||||
openwiki
|
||||
@@ -42,8 +58,12 @@ jobs:
|
||||
.github/workflows/openwiki-update.yml
|
||||
branch: openwiki/update
|
||||
commit-message: "docs: update OpenWiki"
|
||||
url: ${{ secrets.URL }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
base_branch: master
|
||||
head_branch: openwiki/update
|
||||
title: "docs: update OpenWiki"
|
||||
body: |
|
||||
Automated OpenWiki documentation update.
|
||||
|
||||
This PR was generated by the scheduled OpenWiki workflow.
|
||||
This PR was generated by the scheduled OpenWiki workflow.
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -361,6 +361,8 @@ MigrationBackup/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
# Legacy: urspruenglich SQLite-Dateien — die Anwendung nutzt inzwischen PostgreSQL (via Aspire).
|
||||
# Eintraege bleiben als Sicherheitsnetz, falls lokal noch alte DB-Dateien existieren.
|
||||
/SlipItIn.Server/slipitIn.db
|
||||
/SlipItIn.Server/slipitIn.db-shm
|
||||
/SlipItIn.Server/slipitIn.db-wal
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
## OpenWiki
|
||||
|
||||
This repository uses OpenWiki for recurring code documentation. Start with `openwiki/quickstart.md`, then follow its links to architecture, workflows, domain concepts, operations, integrations, testing guidance, and source maps.
|
||||
This repository has a generated `openwiki/` evidence index. It is optional just-in-time context, not required startup reading.
|
||||
|
||||
- Treat source code and tests as authoritative. A brief's unknowns and review items are verification gaps, not automatic requirements.
|
||||
- Prefer the narrowest quiet validation that proves the changed behavior. Preserve complete failure output.
|
||||
|
||||
The scheduled OpenWiki GitHub Actions workflow refreshes the repository wiki. Do not hand-edit generated OpenWiki pages unless explicitly asked; prefer updating source code/docs and letting OpenWiki regenerate.
|
||||
|
||||
|
||||
@@ -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
@@ -2,7 +2,10 @@
|
||||
|
||||
## OpenWiki
|
||||
|
||||
This repository uses OpenWiki for recurring code documentation. Start with `openwiki/quickstart.md`, then follow its links to architecture, workflows, domain concepts, operations, integrations, testing guidance, and source maps.
|
||||
This repository has a generated `openwiki/` evidence index. It is optional just-in-time context, not required startup reading.
|
||||
|
||||
- Treat source code and tests as authoritative. A brief's unknowns and review items are verification gaps, not automatic requirements.
|
||||
- Prefer the narrowest quiet validation that proves the changed behavior. Preserve complete failure output.
|
||||
|
||||
The scheduled OpenWiki GitHub Actions workflow refreshes the repository wiki. Do not hand-edit generated OpenWiki pages unless explicitly asked; prefer updating source code/docs and letting OpenWiki regenerate.
|
||||
|
||||
|
||||
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 db = builder.AddPostgres("pgsql")
|
||||
.AddDatabase("postgresdb")
|
||||
;
|
||||
.AddDatabase("postgresdb");
|
||||
|
||||
var server = builder.AddProject<Projects.SlipItIn_Server>("server")
|
||||
.WithReference(db)
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.4.6" />
|
||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.4.6" />
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.5.2" />
|
||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.5.2" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.8" />
|
||||
</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 IConfiguration _configuration;
|
||||
private readonly IHostEnvironment _environment;
|
||||
|
||||
public AuthController(IDbContextFactory<SlipItInDbContext> contextFactory, IConfiguration configuration)
|
||||
public AuthController(IDbContextFactory<SlipItInDbContext> contextFactory, IConfiguration configuration, IHostEnvironment environment)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
_configuration = configuration;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
@@ -30,6 +32,9 @@ public class AuthController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return BadRequest("Username, Email und Passwort sind erforderlich.");
|
||||
|
||||
if (request.Password.Length < 8)
|
||||
return BadRequest("Das Passwort muss mindestens 8 Zeichen lang sein.");
|
||||
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var normalizedEmail = request.Email.Trim().ToLowerInvariant();
|
||||
@@ -86,7 +91,7 @@ public class AuthController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
UserId = int.Parse(userIdClaim),
|
||||
UserId = int.TryParse(userIdClaim, out var parsedUserId) ? parsedUserId : 0,
|
||||
Username = username,
|
||||
Email = email
|
||||
});
|
||||
@@ -94,7 +99,13 @@ public class AuthController : ControllerBase
|
||||
|
||||
private AuthResponseDto CreateAuthResponse(User user)
|
||||
{
|
||||
var jwtKey = _configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
|
||||
var jwtKey = _configuration["Jwt:Key"];
|
||||
if (string.IsNullOrWhiteSpace(jwtKey))
|
||||
{
|
||||
if (!_environment.IsDevelopment())
|
||||
throw new InvalidOperationException("Jwt:Key ist nicht konfiguriert.");
|
||||
jwtKey = "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef";
|
||||
}
|
||||
var jwtIssuer = _configuration["Jwt:Issuer"] ?? "SlipItInServer";
|
||||
var jwtAudience = _configuration["Jwt:Audience"] ?? "SlipItInClient";
|
||||
var expirationMinutes = int.TryParse(_configuration["Jwt:ExpirationMinutes"], out var parsedMinutes) ? parsedMinutes : 1440;
|
||||
|
||||
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)
|
||||
{
|
||||
// Geprüfte Fachfehler mit festen, harmonsierten Texten dürfen an den Client.
|
||||
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -259,10 +260,16 @@ public class GameHub : Hub
|
||||
var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetCardId);
|
||||
var game = await _gameService.GetGameAsync(gameId);
|
||||
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
var challengingPlayer = await context.Players
|
||||
.Include(p => p.User)
|
||||
.FirstOrDefaultAsync(p => p.Id == challengingPlayerId);
|
||||
|
||||
var challengeDto = new SlipChallengeDto
|
||||
{
|
||||
ChallengeId = challenge.Id,
|
||||
ChallengingPlayerId = challengingPlayerId,
|
||||
ChallengingPlayerName = challengingPlayer?.User.Username ?? string.Empty,
|
||||
TargetPlayerId = challenge.TargetPlayerId,
|
||||
TargetCardId = targetCardId,
|
||||
Status = challenge.Status.ToString(),
|
||||
@@ -272,7 +279,6 @@ public class GameHub : Hub
|
||||
await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto);
|
||||
|
||||
// Sende direkte Notification an den beschuldigten Spieler
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
var targetPlayer = await context.Players.FindAsync(challenge.TargetPlayerId);
|
||||
if (targetPlayer?.ConnectionId != null)
|
||||
{
|
||||
@@ -372,9 +378,26 @@ public class GameHub : Hub
|
||||
{
|
||||
player.ConnectionId = Context.ConnectionId;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// (Re-)Join der Lobby-Gruppe, damit Group-Broadcasts nach einem Reconnect wieder ankommen.
|
||||
var lobbyCode = await context.Games
|
||||
.Where(g => g.Id == player.GameId)
|
||||
.Select(g => g.LobbyCode)
|
||||
.FirstAsync();
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, lobbyCode);
|
||||
|
||||
// Der Client bekommt nach einem Reconnect keinen vollständigen Zustand
|
||||
// mehr aus der Queue gespielt — er muss RequestGameState aufrufen.
|
||||
}
|
||||
}
|
||||
catch { /* Ignorieren falls nicht authentifiziert */ }
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Nicht authentifiziert — ConnectionId wird nicht gespeichert.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to register connection for authenticated user");
|
||||
}
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
@@ -382,6 +405,23 @@ public class GameHub : Hub
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
_logger.LogInformation("Client {ConnectionId} disconnected", Context.ConnectionId);
|
||||
|
||||
// ConnectionId zurücksetzen, damit keine Nachrichten an eine tote Verbindung gehen.
|
||||
try
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
var players = await context.Players
|
||||
.Where(p => p.ConnectionId == Context.ConnectionId)
|
||||
.ToListAsync();
|
||||
foreach (var player in players)
|
||||
player.ConnectionId = null;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to clear connection id on disconnect");
|
||||
}
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using SlipItIn.Server.Data;
|
||||
using SlipItIn.Server.Hubs;
|
||||
using SlipItIn.Server.Services;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -13,13 +14,27 @@ builder.AddServiceDefaults();
|
||||
|
||||
builder.AddNpgsqlDataSource(connectionName: "postgresdb");
|
||||
|
||||
builder.Services.AddDbContextFactory<SlipItInDbContext>(options => options.UseNpgsql());
|
||||
builder.Services.AddDbContextFactory<SlipItInDbContext>((sp, options) =>
|
||||
options.UseNpgsql(sp.GetRequiredService<Npgsql.NpgsqlDataSource>()));
|
||||
|
||||
// Services registrieren
|
||||
builder.Services.AddTransient<IGameService, GameService>();
|
||||
|
||||
// JWT Authentication
|
||||
var jwtKey = builder.Configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
|
||||
var jwtKey = builder.Configuration["Jwt:Key"];
|
||||
if (string.IsNullOrWhiteSpace(jwtKey) || jwtKey.Length < 32)
|
||||
{
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
jwtKey = "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef";
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Jwt:Key ist nicht konfiguriert oder kürzer als 32 Zeichen. " +
|
||||
"In Production muss ein eigener Schlüssel via Konfiguration/Umgebungsvariable gesetzt werden.");
|
||||
}
|
||||
}
|
||||
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "SlipItInServer";
|
||||
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "SlipItInClient";
|
||||
|
||||
@@ -50,6 +65,24 @@ builder.Services
|
||||
context.Token = accessToken;
|
||||
}
|
||||
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)
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
// Migrations anwenden
|
||||
// Migrations anwenden und Testdaten seeden (nur wenn die Datenbank leer ist)
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SlipItInDbContext>();
|
||||
db.Database.Migrate();
|
||||
await DbSeeder.SeedAsync(db);
|
||||
}
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
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.UseAuthorization();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using SlipItIn.Server.Data;
|
||||
using SlipItIn.Shared.DTOs;
|
||||
using SlipItIn.Shared.Models;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace SlipItIn.Server.Services;
|
||||
|
||||
@@ -21,7 +22,7 @@ public class GameService : IGameService
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var host = await context.Users.FirstOrDefaultAsync(u => u.Id == hostUserId) ?? throw new InvalidOperationException("Host not found");
|
||||
var lobbyCode = GenerateLobbyCode();
|
||||
var lobbyCode = await GenerateUniqueLobbyCodeAsync(context);
|
||||
var game = new Game
|
||||
{
|
||||
LobbyCode = lobbyCode,
|
||||
@@ -70,7 +71,16 @@ public class GameService : IGameService
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
|
||||
var game = await context.Games
|
||||
.Include(g => g.Players)
|
||||
.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
|
||||
|
||||
if (game.Status != GameStatus.Lobby)
|
||||
throw new InvalidOperationException("Game has already started or ended");
|
||||
|
||||
if (game.Players.Count < 2)
|
||||
throw new InvalidOperationException("At least 2 players are required to start the game");
|
||||
|
||||
game.Status = GameStatus.InProgress;
|
||||
game.StartedAt = DateTime.UtcNow;
|
||||
|
||||
@@ -85,12 +95,16 @@ public class GameService : IGameService
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId) ?? throw new InvalidOperationException("Player not found");
|
||||
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
|
||||
if (game.Status != GameStatus.Lobby)
|
||||
throw new InvalidOperationException("Game is not in lobby status");
|
||||
|
||||
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId && p.GameId == gameId)
|
||||
?? throw new InvalidOperationException("Player not found in this game");
|
||||
player.IsReady = true;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return await context.Games.FirstOrDefaultAsync(g => g.Id == gameId)
|
||||
?? throw new InvalidOperationException("Game not found");
|
||||
return game;
|
||||
}
|
||||
|
||||
public async Task<List<PlayerCard>> DealCardsAsync(int gameId)
|
||||
@@ -108,7 +122,7 @@ public class GameService : IGameService
|
||||
|
||||
foreach (var player in players)
|
||||
{
|
||||
var selectedPhrases = allPhrases.OrderBy(_ => Random.Shared.Next()).Take(5);
|
||||
var selectedPhrases = allPhrases.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue)).Take(5);
|
||||
|
||||
foreach (var phrase in selectedPhrases)
|
||||
{
|
||||
@@ -125,6 +139,7 @@ public class GameService : IGameService
|
||||
}
|
||||
|
||||
currentRound.Status = RoundStatus.Active;
|
||||
currentRound.StartedAt = DateTime.UtcNow;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return dealtCards;
|
||||
@@ -134,10 +149,18 @@ public class GameService : IGameService
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
|
||||
if (game.Status != GameStatus.InProgress)
|
||||
throw new InvalidOperationException("Game is not in progress");
|
||||
|
||||
var card = await context.PlayerCards
|
||||
.Include(pc => pc.Player)
|
||||
.Include(pc => pc.GameRound)
|
||||
.FirstOrDefaultAsync(pc => pc.Id == cardId) ?? throw new InvalidOperationException("Card not found");
|
||||
|
||||
if (card.PlayerId != playerId) throw new UnauthorizedAccessException("Card does not belong to player");
|
||||
if (card.GameRound.GameId != gameId) throw new InvalidOperationException("Card does not belong to this game");
|
||||
if (card.GameRound.Status != RoundStatus.Active) throw new InvalidOperationException("Round is not active");
|
||||
if (card.IsUsed) throw new InvalidOperationException("Card has already been used");
|
||||
|
||||
card.IsUsed = true;
|
||||
await context.SaveChangesAsync();
|
||||
@@ -149,13 +172,29 @@ public class GameService : IGameService
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var targetCard = await context.PlayerCards.FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found");
|
||||
|
||||
var currentRound = await context.GameRounds
|
||||
.Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Active)
|
||||
.FirstOrDefaultAsync() ?? throw new InvalidOperationException("No active round found");
|
||||
|
||||
if (currentRound == null) throw new InvalidOperationException("No active round found");
|
||||
var 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
|
||||
{
|
||||
@@ -179,19 +218,30 @@ public class GameService : IGameService
|
||||
var challenge = await context.SlipChallenges
|
||||
.Include(sc => sc.GameRound)
|
||||
.Include(sc => sc.TargetCard)
|
||||
.Include(sc => sc.ChallengingPlayer)
|
||||
.Include(sc => sc.TargetPlayer)
|
||||
.FirstOrDefaultAsync(sc => sc.Id == challengeId) ?? throw new InvalidOperationException("Challenge not found");
|
||||
|
||||
// approved = Beschuldigung stimmt (Phrase war falsch)
|
||||
// rejected = Beschuldigung stimmt nicht (Phrase war richtig)
|
||||
if (challenge.Status != ChallengeStatus.Pending)
|
||||
throw new InvalidOperationException("Challenge has already been resolved");
|
||||
|
||||
if (!approved)
|
||||
// approved = Beschuldigung stimmt (Phrase war ein unberechtigtes SlipIn des Beschuldigten)
|
||||
// rejected = Beschuldigung stimmt nicht (Phrase wurde regelkonform gespielt)
|
||||
|
||||
if (approved)
|
||||
{
|
||||
// FALSCHE BESCHULDIGUNG: Beschuldiger erhält die Phrase des Beschuldigten
|
||||
var targetCard = challenge.TargetCard;
|
||||
targetCard.PlayerId = challenge.ChallengingPlayerId;
|
||||
context.PlayerCards.Update(targetCard);
|
||||
// Berechtigte Beschuldigung: Beschuldigter muss die Phrase an den Beschuldiger abgeben
|
||||
challenge.TargetCard.PlayerId = challenge.ChallengingPlayerId;
|
||||
challenge.ChallengingPlayer.Score += 1;
|
||||
challenge.TargetPlayer.FailedSlips += 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Falsche Beschuldigung: Phrase bleibt beim Beschuldigten
|
||||
challenge.TargetPlayer.SuccessfulSlips += 1;
|
||||
challenge.TargetPlayer.Score += 1;
|
||||
challenge.ChallengingPlayer.FailedSlips += 1;
|
||||
}
|
||||
// Wenn approved: Phrase bleibt bei Beschuldigtem (= Phrase war tatsächlich ein SlipIn)
|
||||
|
||||
challenge.Status = approved ? ChallengeStatus.Approved : ChallengeStatus.Rejected;
|
||||
challenge.ResolvedAt = DateTime.UtcNow;
|
||||
@@ -218,7 +268,7 @@ public class GameService : IGameService
|
||||
Username = p.User.Username,
|
||||
Score = p.Score,
|
||||
IsReady = p.IsReady,
|
||||
CardCount = p.Cards.Count // Nur Anzahl, nicht die Karten selbst!
|
||||
CardCount = p.Cards.Count(c => !c.IsUsed) // Nur Anzahl, nicht die Karten selbst!
|
||||
}).ToList();
|
||||
|
||||
var currentRound = game.Rounds
|
||||
@@ -249,9 +299,17 @@ public class GameService : IGameService
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
// Nur Karten der aktuell aktiven Runde zurückgeben — sonst wächst die Hand
|
||||
// über Runden hinweg unbegrenzt und enthält veraltete Karten.
|
||||
var activeRoundIds = await context.GameRounds
|
||||
.Where(gr => gr.Status == RoundStatus.Active &&
|
||||
gr.Game.Players.Any(p => p.Id == playerId))
|
||||
.Select(gr => gr.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var cards = await context.PlayerCards
|
||||
.Include(pc => pc.Phrase)
|
||||
.Where(pc => pc.PlayerId == playerId)
|
||||
.Where(pc => pc.PlayerId == playerId && activeRoundIds.Contains(pc.GameRoundId))
|
||||
.Select(pc => new PlayerCardDto
|
||||
{
|
||||
CardId = pc.Id,
|
||||
@@ -275,11 +333,25 @@ public class GameService : IGameService
|
||||
?? throw new InvalidOperationException("Game not found");
|
||||
}
|
||||
|
||||
private string GenerateLobbyCode()
|
||||
private static string GenerateLobbyCode()
|
||||
{
|
||||
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
var code = new string([.. Enumerable.Range(0, 6).Select(_ => chars[Random.Shared.Next(chars.Length)])]);
|
||||
var buffer = new char[6];
|
||||
for (var i = 0; i < buffer.Length; i++)
|
||||
buffer[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
return code;
|
||||
private static async Task<string> GenerateUniqueLobbyCodeAsync(SlipItInDbContext context, int maxAttempts = 10)
|
||||
{
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++)
|
||||
{
|
||||
var code = GenerateLobbyCode();
|
||||
var exists = await context.Games.AnyAsync(g => g.LobbyCode == code);
|
||||
if (!exists)
|
||||
return code;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not generate a unique lobby code");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql" Version="13.4.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||
<PackageReference Include="Aspire.Npgsql" Version="13.5.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</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="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,7 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"postgresdb": "Server=(localdb)\\postgres;Database=postgresdb"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
|
||||
"Issuer": "SlipItInServer",
|
||||
"Audience": "SlipItInClient",
|
||||
"ExpirationMinutes": 1440
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.8.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.8.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.9.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.9.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.18.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<Solution>
|
||||
<Folder Name="/Projektmappenelemente/">
|
||||
<File Path="Agents/Architecture.md" />
|
||||
<File Path="Agents/ProjectPlan.md" />
|
||||
</Folder>
|
||||
<Project Path="SlipItIN.AppHost/SlipItIn.AppHost.csproj" />
|
||||
<Project Path="SlipItIn.AppHost/SlipItIn.AppHost.csproj" />
|
||||
<Project Path="SlipItIn.Server.Tests/SlipItIn.Server.Tests.csproj" />
|
||||
<Project Path="SlipItIn.Server/SlipItIn.Server.csproj" />
|
||||
<Project Path="SlipItIN.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
|
||||
<Project Path="SlipItIn.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
|
||||
<Project Path="SlipItIn.Shared/SlipItIn.Shared.csproj" />
|
||||
<Project Path="SlipItIN/SlipItIn.csproj">
|
||||
<Project Path="SlipItIn/SlipItIn.csproj">
|
||||
<Deploy Solution="Debug|*" />
|
||||
</Project>
|
||||
</Solution>
|
||||
|
||||
@@ -5,37 +5,82 @@ namespace SlipItIn;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private readonly SemaphoreSlim _startupLock = new(1, 1);
|
||||
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
|
||||
protected override Window CreateWindow(IActivationState? activationState)
|
||||
{
|
||||
var window = new Window(new AppShell());
|
||||
|
||||
window.Created += async (_, _) =>
|
||||
{
|
||||
await InitializeSafeAsync();
|
||||
};
|
||||
|
||||
window.Resumed += async (_, _) =>
|
||||
{
|
||||
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
|
||||
await gameStateService.ResyncAsync();
|
||||
try
|
||||
{
|
||||
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
|
||||
await gameStateService.ResyncAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Resume darf nicht abstürzen; Sync-Status wurde bereits gemeldet.
|
||||
}
|
||||
};
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
private static async Task InitializeAsync()
|
||||
private async Task InitializeSafeAsync()
|
||||
{
|
||||
var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>();
|
||||
var signalR = ServiceHelper.GetRequiredService<ISignalRService>();
|
||||
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
|
||||
if (!await _startupLock.WaitAsync(0))
|
||||
return;
|
||||
|
||||
await authSession.InitializeAsync();
|
||||
await gameStateService.InitializeAsync();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(authSession.AccessToken))
|
||||
try
|
||||
{
|
||||
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);
|
||||
if (connected)
|
||||
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"
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:views="clr-namespace:SlipItIn.Views">
|
||||
xmlns:views="clr-namespace:SlipItIn.Views"
|
||||
FlyoutBehavior="Disabled">
|
||||
|
||||
<ShellContent
|
||||
Route="LoginPage"
|
||||
<ShellContent
|
||||
Title="Login"
|
||||
Route="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>
|
||||
|
||||
@@ -7,5 +7,8 @@ public partial class AppShell : Shell
|
||||
public AppShell()
|
||||
{
|
||||
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 ArgumentsJson { get; set; } = "[]";
|
||||
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)
|
||||
=> 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();
|
||||
|
||||
private async Task<AuthResponseDto> PostAuthAsync<TRequest>(string endpoint, TRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -4,9 +4,20 @@ namespace SlipItIn.Services;
|
||||
|
||||
public class AppConfigurationService : IAppConfigurationService
|
||||
{
|
||||
private const string ApiBaseUrlPreferenceKey = "api_base_url";
|
||||
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 ILocalStorageService _localStorage;
|
||||
private readonly IMessenger _messenger;
|
||||
private readonly SemaphoreSlim _syncLock = new(1, 1);
|
||||
|
||||
private readonly List<QueuedGameAction> _queuedActions = [];
|
||||
|
||||
@@ -27,21 +28,21 @@ public class GameStateService : IGameStateService
|
||||
|
||||
_signalR.LobbyCreated += (_, value) => _messenger.Send(new LobbyCreatedMessage(value));
|
||||
|
||||
_signalR.GameStateUpdated += async (_, state) =>
|
||||
_signalR.GameStateUpdated += (_, state) => RunSafeBridge(async () =>
|
||||
{
|
||||
CurrentGameState = state;
|
||||
await _localStorage.SaveGameStateAsync(state);
|
||||
_messenger.Send(new GameStateChangedMessage(state));
|
||||
};
|
||||
});
|
||||
|
||||
_signalR.GameStarted += (_, gameId) => _messenger.Send(new GameStartedMessage(gameId));
|
||||
|
||||
_signalR.PlayerHandUpdated += async (_, hand) =>
|
||||
_signalR.PlayerHandUpdated += (_, hand) => RunSafeBridge(async () =>
|
||||
{
|
||||
CurrentPlayerHand = hand;
|
||||
await _localStorage.SavePlayerHandAsync(hand);
|
||||
_messenger.Send(new PlayerHandChangedMessage(hand));
|
||||
};
|
||||
});
|
||||
|
||||
_signalR.ChallengeReceived += (_, challenge) =>
|
||||
{
|
||||
@@ -50,7 +51,26 @@ public class GameStateService : IGameStateService
|
||||
};
|
||||
|
||||
_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()
|
||||
@@ -67,14 +87,32 @@ public class GameStateService : IGameStateService
|
||||
|
||||
if (CurrentPlayerHand is not null)
|
||||
_messenger.Send(new PlayerHandChangedMessage(CurrentPlayerHand));
|
||||
|
||||
PublishSyncState(false, "Bereit", _queuedActions.Count);
|
||||
}
|
||||
|
||||
public async Task ResyncAsync()
|
||||
{
|
||||
if (CurrentGameState?.GameId > 0 && _signalR.IsConnected)
|
||||
await _signalR.RequestGameStateAsync(CurrentGameState.GameId);
|
||||
await _syncLock.WaitAsync();
|
||||
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)
|
||||
@@ -88,21 +126,22 @@ public class GameStateService : IGameStateService
|
||||
|
||||
_queuedActions.Add(action);
|
||||
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
|
||||
PublishSyncState(false, "Offline-Aktion gespeichert", _queuedActions.Count);
|
||||
}
|
||||
|
||||
public async Task FlushQueuedActionsAsync()
|
||||
{
|
||||
if (!_signalR.IsConnected || _queuedActions.Count == 0)
|
||||
return;
|
||||
|
||||
var snapshot = _queuedActions.ToList();
|
||||
foreach (var action in snapshot)
|
||||
await _syncLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await _signalR.SendQueuedActionAsync(action);
|
||||
_queuedActions.Remove(action);
|
||||
PublishSyncState(true, "Warteschlange wird gesendet...", _queuedActions.Count);
|
||||
await FlushQueuedActionsCoreAsync();
|
||||
PublishSyncState(false, "Warteschlange synchronisiert", _queuedActions.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_syncLock.Release();
|
||||
}
|
||||
|
||||
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
|
||||
}
|
||||
|
||||
public async Task ClearLocalGameDataAsync()
|
||||
@@ -115,5 +154,52 @@ public class GameStateService : IGameStateService
|
||||
await _localStorage.ClearGameStateAsync();
|
||||
await _localStorage.ClearPlayerHandAsync();
|
||||
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;
|
||||
|
||||
public enum SessionValidationResult
|
||||
{
|
||||
Valid,
|
||||
Invalid,
|
||||
NetworkError
|
||||
}
|
||||
|
||||
public interface IApiService
|
||||
{
|
||||
Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
|
||||
Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
|
||||
Task<SessionValidationResult> ValidateSessionAsync(CancellationToken cancellationToken = default);
|
||||
Task LogoutAsync();
|
||||
}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SlipItIn.Models;
|
||||
using SlipItIn.Services.Interfaces;
|
||||
using SlipItIn.Shared.DTOs;
|
||||
|
||||
namespace SlipItIn.Services;
|
||||
|
||||
public class SignalRService : ISignalRService
|
||||
public class SignalRService : ISignalRService, IAsyncDisposable
|
||||
{
|
||||
private readonly IAppConfigurationService _configuration;
|
||||
private readonly IAuthSessionService _authSession;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly SemaphoreSlim _connectLock = new(1, 1);
|
||||
private HubConnection? _hubConnection;
|
||||
|
||||
public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession)
|
||||
public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession, IServiceProvider serviceProvider)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_authSession = authSession;
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected;
|
||||
@@ -33,31 +37,76 @@ public class SignalRService : ISignalRService
|
||||
if (IsConnected)
|
||||
return true;
|
||||
|
||||
EnsureConnection(token);
|
||||
|
||||
if (_hubConnection is null)
|
||||
return false;
|
||||
|
||||
await _connectLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await _hubConnection.StartAsync(cancellationToken);
|
||||
ConnectionStateChanged?.Invoke(this, true);
|
||||
return true;
|
||||
if (IsConnected)
|
||||
return true;
|
||||
|
||||
// Bestehende (möglicherweise tote) Verbindung vollständig entsorgen,
|
||||
// damit der AccessTokenProvider keinen veralteten Token-Closure verwendet.
|
||||
if (_hubConnection is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
|
||||
await _hubConnection.StopAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort; Dispose folgt trotzdem.
|
||||
}
|
||||
|
||||
await _hubConnection.DisposeAsync();
|
||||
_hubConnection = null;
|
||||
}
|
||||
|
||||
EnsureConnection(token);
|
||||
|
||||
if (_hubConnection is null)
|
||||
return false;
|
||||
|
||||
ConnectionStateChanged?.Invoke(this, false);
|
||||
|
||||
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}");
|
||||
return false;
|
||||
_connectLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
if (_hubConnection is null)
|
||||
return;
|
||||
await _connectLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_hubConnection is null)
|
||||
return;
|
||||
|
||||
await _hubConnection.StopAsync();
|
||||
ConnectionStateChanged?.Invoke(this, false);
|
||||
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
|
||||
await _hubConnection.StopAsync();
|
||||
|
||||
await _hubConnection.DisposeAsync();
|
||||
_hubConnection = null;
|
||||
|
||||
ConnectionStateChanged?.Invoke(this, false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task CreateLobbyAsync() => InvokeAsync("CreateLobby");
|
||||
@@ -82,7 +131,7 @@ public class SignalRService : ISignalRService
|
||||
public async Task SendQueuedActionAsync(QueuedGameAction action)
|
||||
{
|
||||
if (_hubConnection is null)
|
||||
return;
|
||||
throw new InvalidOperationException("Keine aktive SignalR-Verbindung.");
|
||||
|
||||
object?[] args;
|
||||
try
|
||||
@@ -90,11 +139,11 @@ public class SignalRService : ISignalRService
|
||||
var doc = JsonDocument.Parse(action.ArgumentsJson);
|
||||
args = doc.RootElement.ValueKind == JsonValueKind.Array
|
||||
? doc.RootElement.EnumerateArray().Select(ToObject).ToArray()
|
||||
: [];
|
||||
: throw new InvalidOperationException($"Ungültige Queue-Aktion: {action.ArgumentsJson}");
|
||||
}
|
||||
catch
|
||||
catch (JsonException ex)
|
||||
{
|
||||
args = [];
|
||||
throw new InvalidOperationException($"Queue-Aktion '{action.Method}' ist beschädigt.", ex);
|
||||
}
|
||||
|
||||
await InvokeAsync(action.Method, args);
|
||||
@@ -108,11 +157,18 @@ public class SignalRService : ISignalRService
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.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)])
|
||||
.Build();
|
||||
|
||||
_hubConnection.Reconnecting += _ =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(this, false);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Closed += _ =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(this, false);
|
||||
@@ -144,6 +200,13 @@ public class SignalRService : ISignalRService
|
||||
_hubConnection.On<PlayerHandDto>("PlayerHandUpdated", dto => PlayerHandUpdated?.Invoke(this, dto));
|
||||
_hubConnection.On<SlipChallengeDto>("ChallengeReceived", dto => ChallengeReceived?.Invoke(this, dto));
|
||||
|
||||
// Server-Broadcasts nach Slip/Challenge/Resolve, damit die UI auch ohne
|
||||
// vollständiges GameState-Update reagieren kann.
|
||||
_hubConnection.On<JsonElement>("SlipSubmitted", _ => RequestCurrentGameStateSafe());
|
||||
_hubConnection.On<JsonElement>("SlipChallenged", _ => RequestCurrentGameStateSafe());
|
||||
_hubConnection.On<JsonElement>("ChallengeResolved", _ => RequestCurrentGameStateSafe());
|
||||
_hubConnection.On<JsonElement>("CardsDealt", _ => RequestCurrentGameStateSafe());
|
||||
|
||||
_hubConnection.On<JsonElement>("Error", payload =>
|
||||
{
|
||||
var message = payload.TryGetProperty("message", out var msgElement)
|
||||
@@ -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)
|
||||
{
|
||||
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
|
||||
@@ -175,4 +258,10 @@ public class SignalRService : ISignalRService
|
||||
_ => 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>
|
||||
<TargetFrameworks>net10.0-android</TargetFrameworks>
|
||||
@@ -48,35 +48,11 @@
|
||||
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-android|AnyCPU'">
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-ios|AnyCPU'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-maccatalyst|AnyCPU'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-windows10.0.19041.0|AnyCPU'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-ios|AnyCPU'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-maccatalyst|AnyCPU'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-windows10.0.19041.0|AnyCPU'">
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Release'">
|
||||
<ApplicationTitle>SlipItIn</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -89,7 +65,6 @@
|
||||
|
||||
<!-- Images -->
|
||||
<MauiImage Include="Resources\Images\*" />
|
||||
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
|
||||
|
||||
<!-- Custom Fonts -->
|
||||
<MauiFont Include="Resources\Fonts\*" />
|
||||
@@ -99,10 +74,10 @@
|
||||
</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="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.10" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -121,4 +96,12 @@
|
||||
</MauiXaml>
|
||||
</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>
|
||||
|
||||
@@ -12,11 +12,15 @@ public partial class GameBoardViewModel : BaseViewModel,
|
||||
IRecipient<GameStateChangedMessage>,
|
||||
IRecipient<PlayerHandChangedMessage>,
|
||||
IRecipient<ChallengeReceivedMessage>,
|
||||
IRecipient<ErrorOccurredMessage>
|
||||
IRecipient<ErrorOccurredMessage>,
|
||||
IRecipient<SyncStateChangedMessage>,
|
||||
IRecipient<ConnectionStateChangedMessage>
|
||||
{
|
||||
private readonly ISignalRService _signalR;
|
||||
private readonly IGameStateService _gameStateService;
|
||||
private readonly IAuthSessionService _authSession;
|
||||
private readonly IDispatcherTimer? _roundTimer;
|
||||
private DateTime _lastStateUtc = DateTime.UtcNow;
|
||||
|
||||
[ObservableProperty]
|
||||
private int gameId;
|
||||
@@ -33,6 +37,18 @@ public partial class GameBoardViewModel : BaseViewModel,
|
||||
[ObservableProperty]
|
||||
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<PlayerInfoDto> OtherPlayers { get; } = [];
|
||||
|
||||
@@ -42,8 +58,28 @@ public partial class GameBoardViewModel : BaseViewModel,
|
||||
_gameStateService = gameStateService;
|
||||
_authSession = authSession;
|
||||
|
||||
WeakReferenceMessenger.Default.RegisterAll(this);
|
||||
IsConnected = _signalR.IsConnected;
|
||||
IsActive = true;
|
||||
|
||||
if (Application.Current?.Dispatcher is { } dispatcher)
|
||||
{
|
||||
_roundTimer = dispatcher.CreateTimer();
|
||||
_roundTimer.Interval = TimeSpan.FromSeconds(1);
|
||||
_roundTimer.Tick += OnRoundTimerTick;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRoundTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (RoundTimeRemaining <= 0)
|
||||
return;
|
||||
|
||||
var elapsed = (int)(DateTime.UtcNow - _lastStateUtc).TotalSeconds;
|
||||
if (elapsed > 0)
|
||||
{
|
||||
RoundTimeRemaining = Math.Max(0, RoundTimeRemaining - elapsed);
|
||||
_lastStateUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -109,9 +145,22 @@ public partial class GameBoardViewModel : BaseViewModel,
|
||||
if (PendingChallenge is null)
|
||||
return;
|
||||
|
||||
await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, true);
|
||||
var challengeId = PendingChallenge.ChallengeId;
|
||||
PendingChallenge = null;
|
||||
IsChallengeVisible = false;
|
||||
|
||||
await RunSafeAsync(async () =>
|
||||
{
|
||||
if (_signalR.IsConnected)
|
||||
{
|
||||
await _signalR.ResolveChallengeAsync(challengeId, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _gameStateService.EnqueueActionAsync("ResolveChallenge", challengeId, true);
|
||||
StatusMessage = "Offline: Antwort wurde zwischengespeichert.";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -120,9 +169,22 @@ public partial class GameBoardViewModel : BaseViewModel,
|
||||
if (PendingChallenge is null)
|
||||
return;
|
||||
|
||||
await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, false);
|
||||
var challengeId = PendingChallenge.ChallengeId;
|
||||
PendingChallenge = null;
|
||||
IsChallengeVisible = false;
|
||||
|
||||
await RunSafeAsync(async () =>
|
||||
{
|
||||
if (_signalR.IsConnected)
|
||||
{
|
||||
await _signalR.ResolveChallengeAsync(challengeId, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _gameStateService.EnqueueActionAsync("ResolveChallenge", challengeId, false);
|
||||
StatusMessage = "Offline: Antwort wurde zwischengespeichert.";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Receive(GameStateChangedMessage message)
|
||||
@@ -132,6 +194,7 @@ public partial class GameBoardViewModel : BaseViewModel,
|
||||
GameId = message.Value.GameId;
|
||||
CurrentRound = message.Value.CurrentRound;
|
||||
RoundTimeRemaining = message.Value.RoundTimeRemaining;
|
||||
_lastStateUtc = DateTime.UtcNow;
|
||||
|
||||
var me = _authSession.CurrentUser?.Username;
|
||||
var others = message.Value.Players
|
||||
@@ -156,15 +219,36 @@ public partial class GameBoardViewModel : BaseViewModel,
|
||||
|
||||
public void Receive(ChallengeReceivedMessage message)
|
||||
{
|
||||
PendingChallenge = message.Value;
|
||||
IsChallengeVisible = true;
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
PendingChallenge = message.Value;
|
||||
IsChallengeVisible = true;
|
||||
});
|
||||
}
|
||||
|
||||
public void Receive(ErrorOccurredMessage message)
|
||||
{
|
||||
StatusMessage = message.Value;
|
||||
MainThread.BeginInvokeOnMainThread(() => StatusMessage = message.Value);
|
||||
}
|
||||
|
||||
public void Receive(SyncStateChangedMessage message)
|
||||
{
|
||||
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()
|
||||
{
|
||||
var username = _authSession.CurrentUser?.Username;
|
||||
|
||||
@@ -13,7 +13,8 @@ public partial class LobbyViewModel : BaseViewModel,
|
||||
IRecipient<LobbyCreatedMessage>,
|
||||
IRecipient<GameStartedMessage>,
|
||||
IRecipient<ErrorOccurredMessage>,
|
||||
IRecipient<ConnectionStateChangedMessage>
|
||||
IRecipient<ConnectionStateChangedMessage>,
|
||||
IRecipient<SyncStateChangedMessage>
|
||||
{
|
||||
private readonly ISignalRService _signalR;
|
||||
private readonly IGameStateService _gameStateService;
|
||||
@@ -32,6 +33,15 @@ public partial class LobbyViewModel : BaseViewModel,
|
||||
[ObservableProperty]
|
||||
private bool isConnected;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isSyncing;
|
||||
|
||||
[ObservableProperty]
|
||||
private string syncStatusText = "Bereit";
|
||||
|
||||
[ObservableProperty]
|
||||
private int pendingSyncActions;
|
||||
|
||||
public ObservableCollection<PlayerInfoDto> Players { get; } = [];
|
||||
|
||||
public LobbyViewModel(ISignalRService signalR, IGameStateService gameStateService, IAuthSessionService authSession, IApiService apiService)
|
||||
@@ -42,7 +52,16 @@ public partial class LobbyViewModel : BaseViewModel,
|
||||
_apiService = apiService;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -96,6 +115,7 @@ public partial class LobbyViewModel : BaseViewModel,
|
||||
{
|
||||
await RunSafeAsync(async () =>
|
||||
{
|
||||
IsActive = false;
|
||||
await _signalR.DisconnectAsync();
|
||||
await _apiService.LogoutAsync();
|
||||
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)
|
||||
{
|
||||
StatusMessage = message.Value;
|
||||
MainThread.BeginInvokeOnMainThread(() => StatusMessage = message.Value);
|
||||
}
|
||||
|
||||
public void Receive(ConnectionStateChangedMessage message)
|
||||
{
|
||||
IsConnected = message.Value;
|
||||
MainThread.BeginInvokeOnMainThread(() => IsConnected = message.Value);
|
||||
}
|
||||
|
||||
public void Receive(SyncStateChangedMessage message)
|
||||
{
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
IsSyncing = message.Value.IsSyncing;
|
||||
SyncStatusText = message.Value.StatusText;
|
||||
PendingSyncActions = message.Value.PendingActions;
|
||||
});
|
||||
}
|
||||
|
||||
private int GetCurrentPlayerId()
|
||||
|
||||
@@ -14,10 +14,10 @@ public partial class LoginViewModel : BaseViewModel
|
||||
private readonly IGameStateService _gameStateService;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string Email { get; set; }
|
||||
private string email = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string Password { get; set; }
|
||||
private string password = string.Empty;
|
||||
|
||||
public LoginViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR, IGameStateService gameStateService)
|
||||
{
|
||||
@@ -28,7 +28,7 @@ public partial class LoginViewModel : BaseViewModel
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task GoToRegisterAsync() => Shell.Current.GoToAsync($"//{nameof(RegisterPage)}");
|
||||
private Task GoToRegisterAsync() => Shell.Current.GoToAsync(nameof(RegisterPage));
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoginAsync()
|
||||
@@ -42,20 +42,40 @@ public partial class LoginViewModel : BaseViewModel
|
||||
});
|
||||
|
||||
await _authSession.SetSessionAsync(response);
|
||||
await _signalR.ConnectAsync(response.Token);
|
||||
|
||||
var connected = await _signalR.ConnectAsync(response.Token);
|
||||
if (!connected)
|
||||
throw new InvalidOperationException("Verbindung zum Spielserver fehlgeschlagen.");
|
||||
|
||||
await _gameStateService.InitializeAsync();
|
||||
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}");
|
||||
await Shell.Current.GoToAsync(nameof(LobbyPage));
|
||||
}, "Anmeldung läuft...");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
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;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string Username { get; set; } = string.Empty;
|
||||
private string username = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string Email { get; set; } = string.Empty;
|
||||
private string email = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string Password { get; set; } = string.Empty;
|
||||
private string password = string.Empty;
|
||||
|
||||
public RegisterViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR)
|
||||
{
|
||||
@@ -44,8 +44,10 @@ public partial class RegisterViewModel : BaseViewModel
|
||||
});
|
||||
|
||||
await _authSession.SetSessionAsync(response);
|
||||
await _signalR.ConnectAsync(response.Token);
|
||||
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}");
|
||||
var connected = await _signalR.ConnectAsync(response.Token);
|
||||
if (!connected)
|
||||
throw new InvalidOperationException("Verbindung zum Spielserver fehlgeschlagen.");
|
||||
await Shell.Current.GoToAsync(nameof(LobbyPage));
|
||||
}, "Registrierung läuft...");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="SlipItIn.Views.ChallengeNotificationOverlay">
|
||||
xmlns:vm="clr-namespace:SlipItIn.ViewModels"
|
||||
x:Class="SlipItIn.Views.ChallengeNotificationOverlay"
|
||||
x:DataType="vm:GameBoardViewModel">
|
||||
<Grid BackgroundColor="#88000000" IsVisible="{Binding IsChallengeVisible}" Padding="24">
|
||||
<Frame VerticalOptions="Center" HorizontalOptions="Center">
|
||||
<Border VerticalOptions="Center" HorizontalOptions="Center" Stroke="LightGray" StrokeShape="RoundRectangle 12" BackgroundColor="White" Padding="16">
|
||||
<VerticalStackLayout Spacing="10">
|
||||
<Label Text="Du wurdest beschuldigt" FontAttributes="Bold" />
|
||||
<Label Text="{Binding PendingChallenge.ChallengeId, StringFormat='ChallengeId: {0}'}" />
|
||||
@@ -12,6 +14,6 @@
|
||||
<Button Text="Ablehnen" Command="{Binding RejectChallengeCommand}" />
|
||||
</HorizontalStackLayout>
|
||||
</VerticalStackLayout>
|
||||
</Frame>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ContentView>
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
<Label Text="Spielbrett" FontSize="24" FontAttributes="Bold" />
|
||||
<Label Text="{Binding CurrentRound, StringFormat='Runde: {0}'}" />
|
||||
<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}" />
|
||||
|
||||
@@ -17,7 +20,7 @@
|
||||
<CollectionView ItemsSource="{Binding MyCards}">
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Frame Margin="0,4" Padding="10">
|
||||
<Border Margin="0,4" Padding="10" Stroke="LightGray" StrokeThickness="1" StrokeShape="RoundRectangle 10" BackgroundColor="White">
|
||||
<VerticalStackLayout>
|
||||
<Label Text="{Binding Text}" />
|
||||
<HorizontalStackLayout>
|
||||
@@ -25,7 +28,7 @@
|
||||
<Button Text="Beschuldigen" Command="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.ChallengeCardCommand}" CommandParameter="{Binding .}" />
|
||||
</HorizontalStackLayout>
|
||||
</VerticalStackLayout>
|
||||
</Frame>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
|
||||
@@ -5,9 +5,23 @@ namespace SlipItIn.Views;
|
||||
|
||||
public partial class GameBoardPage : ContentPage
|
||||
{
|
||||
private readonly GameBoardViewModel _viewModel;
|
||||
|
||||
public GameBoardPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = ServiceHelper.GetRequiredService<GameBoardViewModel>();
|
||||
BindingContext = _viewModel = ServiceHelper.GetRequiredService<GameBoardViewModel>();
|
||||
}
|
||||
|
||||
protected override void OnNavigatedTo(NavigatedToEventArgs args)
|
||||
{
|
||||
base.OnNavigatedTo(args);
|
||||
_viewModel.StartRoundTimer();
|
||||
}
|
||||
|
||||
protected override void OnNavigatedFrom(NavigatedFromEventArgs args)
|
||||
{
|
||||
base.OnNavigatedFrom(args);
|
||||
_viewModel.StopRoundTimer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<VerticalStackLayout Padding="24" Spacing="12">
|
||||
<Label Text="Lobby" FontSize="24" FontAttributes="Bold" />
|
||||
<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}" />
|
||||
|
||||
|
||||
@@ -5,9 +5,17 @@ namespace SlipItIn.Views;
|
||||
|
||||
public partial class LobbyPage : ContentPage
|
||||
{
|
||||
private readonly LobbyViewModel _viewModel;
|
||||
|
||||
public LobbyPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = ServiceHelper.GetRequiredService<LobbyViewModel>();
|
||||
BindingContext = _viewModel = ServiceHelper.GetRequiredService<LobbyViewModel>();
|
||||
}
|
||||
|
||||
protected override void OnNavigatedTo(NavigatedToEventArgs args)
|
||||
{
|
||||
base.OnNavigatedTo(args);
|
||||
_viewModel.Activate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"appHost": {
|
||||
"path": "SlipItIN.AppHost/SlipItIN.AppHost.csproj"
|
||||
"path": "SlipItIn.AppHost/SlipItIn.AppHost.csproj"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"updatedAt": "2026-07-26T11:33:42.070Z",
|
||||
"command": "init",
|
||||
"gitHead": "070727d5cd1fff7fdd7e0db903f1c696b0045ecd",
|
||||
"model": "gemini-3.6-flash"
|
||||
"updatedAt": "2026-08-08T18:09:49.042Z",
|
||||
"command": "update",
|
||||
"gitHead": "37616a93166c2ac0a46eedab276164f811043f4e",
|
||||
"model": "gemini-3.6-flash",
|
||||
"status": "complete",
|
||||
"language": "en"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Files
|
||||
|
||||
- [System Architecture & Security Overview](overview.md) - Technical architecture of SlipItIn, covering .NET Aspire orchestration, ASP.NET Core SignalR hub design, JWT authentication, and IDbContextFactory thread safety.
|
||||
- [System Architecture & Security Overview](overview.md) - Technical architecture of SlipItIn, covering .NET Aspire orchestration, ASP.NET Core SignalR hub, .NET MAUI MVVM client layer, WeakReferenceMessenger event routing, and dual-layer local storage.
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
---
|
||||
type: Architecture
|
||||
title: System Architecture & Security Overview
|
||||
description: Technical architecture of SlipItIn, covering .NET Aspire orchestration, ASP.NET Core SignalR hub design, JWT authentication, and IDbContextFactory thread safety.
|
||||
tags: [architecture, spire, signalr, jwt, efcore, security]
|
||||
description: Technical architecture of SlipItIn, covering .NET Aspire orchestration, ASP.NET Core SignalR hub, .NET MAUI MVVM client layer, WeakReferenceMessenger event routing, and dual-layer local storage.
|
||||
tags: [architecture, spire, signalr, jwt, efcore, security, maui, mvvm]
|
||||
openwiki:
|
||||
roles: [architecture, domain]
|
||||
change_kinds: [lifecycle, public-api]
|
||||
source_paths: [SlipItIn/MauiProgram.cs, SlipItIn/Services/SignalRService.cs, SlipItIn/Services/GameStateService.cs, SlipItIn.Server/Program.cs, SlipItIn.Server/Hubs/GameHub.cs]
|
||||
symbols: [SignalRService, GameStateService, LocalStorageService, GameHub, GameService, IDbContextFactory]
|
||||
validation_commands: ["dotnet build SlipItIn.slnx"]
|
||||
---
|
||||
|
||||
# System Architecture & Security Overview
|
||||
|
||||
**SlipItIn** is designed as a distributed, real-time application using modern .NET 10 architecture. This document details the orchestration model, backend service structure, security model, and concurrency safeguards.
|
||||
**SlipItIn** is designed as a distributed, real-time application using modern .NET 10 architecture. This document details the orchestration model, backend server structure, .NET MAUI MVVM client architecture, real-time connection resilience, messaging infrastructure, security model, and concurrency safeguards.
|
||||
|
||||
The architectural foundation [orchestrates services with](/openwiki/operations/runbook.md) .NET Aspire, while [enforcing security & privacy on](/openwiki/domain/game-mechanics.md) domain entities and [serving real-time hub endpoints for](/openwiki/workflows/slip-and-challenge.md) all active game sessions. Source code structure for all architectural components can be found in the [Source Code Map](/openwiki/source-map.md).
|
||||
|
||||
@@ -32,14 +38,14 @@ The solution uses **.NET Aspire** to orchestrate application resources, services
|
||||
|
||||
The backend is an ASP.NET Core Web API & SignalR application providing REST endpoints for user management and real-time WebSockets for game state synchronization.
|
||||
|
||||
### Key Components
|
||||
### Key Backend Components
|
||||
|
||||
1. **`Program.cs`**:
|
||||
- Registers `AddServiceDefaults()`, `AddNpgsqlDataSource("postgresdb")`, and `AddDbContextFactory<SlipItInDbContext>()`.
|
||||
- Configures JWT Bearer authentication with custom `OnMessageReceived` token resolution for SignalR.
|
||||
- Automatically executes database migrations (`db.Database.Migrate()`) at startup.
|
||||
2. **`AuthController.cs`**:
|
||||
- Manages user registration (`/api/auth/register`) and authentication (`/api/auth/login`).
|
||||
- Manages user registration (`/api/auth/register`), authentication (`/api/auth/login`), and profile retrieval (`/api/auth/me`).
|
||||
- Uses `BCrypt.Net` for secure password hashing.
|
||||
- Issues JWT tokens signed with `Jwt:Key`, containing `ClaimTypes.NameIdentifier`, `ClaimTypes.Name`, and `ClaimTypes.Email`.
|
||||
3. **`GameHub.cs`**:
|
||||
@@ -48,6 +54,7 @@ The backend is an ASP.NET Core Web API & SignalR application providing REST endp
|
||||
- Extracts and verifies user claims, delegating state mutations to `IGameService`.
|
||||
4. **`GameService.cs`**:
|
||||
- Implements core business logic: game creation, player joins, card dealing, slip submission, challenge creation, and resolution.
|
||||
- Adds `hostPlayer` directly to `game.Players` before `context.Games.Add(game)` to ensure EF Core navigation tracking consistency.
|
||||
|
||||
---
|
||||
|
||||
@@ -101,13 +108,103 @@ sequenceDiagram
|
||||
Client->>Hub: JoinLobby(lobbyCode)
|
||||
Hub->>Hub: GetAuthenticatedUserId()
|
||||
Hub->>Service: JoinGameAsync(lobbyCode, userId, connectionId)
|
||||
Service->>DB: Create IDbContext Session & Save Player
|
||||
Hub-->>Client: Broadcast "PlayerJoined" (GameStateDto)
|
||||
Hub-->>Client: Broadcast PlayerJoined (GameStateDto)
|
||||
```
|
||||
*Sequence diagram showing user authentication via REST and subsequent authenticated SignalR WebSocket connection.*
|
||||
|
||||
---
|
||||
|
||||
## 4. Concurrency Safety: `IDbContextFactory`
|
||||
## 4. .NET MAUI Client Architecture (`SlipItIn`)
|
||||
|
||||
The cross-platform client app uses the **MVVM Pattern** built on `CommunityToolkit.Mvvm` and Dependency Injection configured in `MauiProgram.cs`.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph UI_Layer["UI Layer (Views)"]
|
||||
LoginPage["LoginPage"]
|
||||
RegisterPage["RegisterPage"]
|
||||
LobbyPage["LobbyPage"]
|
||||
GameBoardPage["GameBoardPage"]
|
||||
end
|
||||
|
||||
subgraph ViewModel_Layer["ViewModel Layer"]
|
||||
LoginVM["LoginViewModel"]
|
||||
RegisterVM["RegisterViewModel"]
|
||||
LobbyVM["LobbyViewModel"]
|
||||
GameBoardVM["GameBoardViewModel"]
|
||||
end
|
||||
|
||||
subgraph Messaging_Layer["Messaging Layer"]
|
||||
Messenger["WeakReferenceMessenger"]
|
||||
Messages["Messages (GameStateChanged, PlayerHandChanged, ChallengeReceived)"]
|
||||
end
|
||||
|
||||
subgraph Service_Layer["Service Layer"]
|
||||
GameStateSvc["GameStateService"]
|
||||
SignalRSvc["SignalRService"]
|
||||
AuthSessionSvc["AuthSessionService"]
|
||||
ApiSvc["ApiService"]
|
||||
LocalStorageSvc["LocalStorageService"]
|
||||
end
|
||||
|
||||
LoginPage --> LoginVM
|
||||
RegisterPage --> RegisterVM
|
||||
LobbyPage --> LobbyVM
|
||||
GameBoardPage --> GameBoardVM
|
||||
|
||||
LobbyVM --> Messenger
|
||||
GameBoardVM --> Messenger
|
||||
|
||||
GameStateSvc --> Messenger
|
||||
GameStateSvc --> SignalRSvc
|
||||
GameStateSvc --> LocalStorageSvc
|
||||
LoginVM --> ApiSvc
|
||||
LoginVM --> AuthSessionSvc
|
||||
LoginVM --> SignalRSvc
|
||||
```
|
||||
*Layered architecture diagram of the .NET MAUI client showing Views, ViewModels, WeakReferenceMessenger, and Service implementations.*
|
||||
|
||||
### Dependency Injection & Service Registration (`MauiProgram.cs`)
|
||||
- **Singletons**: `IAppConfigurationService` (`AppConfigurationService`), `ILocalStorageService` (`LocalStorageService`), `IAuthSessionService` (`AuthSessionService`), `IApiService` (`ApiService`), `ISignalRService` (`SignalRService`), `IGameStateService` (`GameStateService`), `LobbyViewModel`, `GameBoardViewModel`.
|
||||
- **Transients**: `LoginViewModel`, `RegisterViewModel`.
|
||||
- **Service Locator Helper**: `ServiceHelper.Services` captures `app.Services` at startup to allow static access where constructor DI is unavailable.
|
||||
|
||||
### Decoupled Messaging Infrastructure (`WeakReferenceMessenger`)
|
||||
To avoid memory leaks caused by long-lived event subscriptions in ViewModels, `GameStateService` translates raw `ISignalRService` hub events into strongly-typed messages dispatched via `WeakReferenceMessenger.Default`:
|
||||
|
||||
- `LobbyCreatedMessage`: Dispatched when a new game lobby code is issued by the server.
|
||||
- `GameStateChangedMessage`: Dispatched when public game state or player list updates.
|
||||
- `GameStartedMessage`: Dispatched when the game transitions to `InProgress`.
|
||||
- `PlayerHandChangedMessage`: Dispatched when a player receives or updates private phrase cards.
|
||||
- `ChallengeReceivedMessage`: Dispatched when a player is directly targeted by a slip challenge.
|
||||
- `ErrorOccurredMessage`: Dispatched when a hub or network error occurs.
|
||||
- `ConnectionStateChangedMessage`: Dispatched when SignalR connection drops or reconnects.
|
||||
|
||||
ViewModels implement `IRecipient<TMessage>` and register via `WeakReferenceMessenger.Default.RegisterAll(this)` to receive automated UI updates.
|
||||
|
||||
### Real-Time Connection Resilience & Auto-Reconnect
|
||||
`SignalRService` configures exponential backoff for WebSocket reconnection:
|
||||
|
||||
```csharp
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(_configuration.HubUrl, options =>
|
||||
{
|
||||
options.AccessTokenProvider = () => Task.FromResult<string?>(token);
|
||||
})
|
||||
.WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)])
|
||||
.Build();
|
||||
```
|
||||
|
||||
When connection drops, `SignalRService` emits `ConnectionStateChanged`. Upon reconnection, `GameStateService` automatically triggers `ResyncAsync()` to fetch current state via `RequestGameStateAsync()` and flush locally queued offline actions.
|
||||
|
||||
### Dual-Layer Storage Security Model (`LocalStorageService`)
|
||||
To satisfy security and privacy constraints:
|
||||
1. **`SecureStorage` (Platform Encrypted Storage)**: Used strictly for `AuthTokenKey` (`auth_token`). Prevents plaintext access to active JWT credentials.
|
||||
2. **`Preferences` (Application Key-Value Settings)**: Used for non-sensitive cached data (`auth_user`, `game_state`, `player_hand`, and `queued_actions`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Concurrency Safety: `IDbContextFactory`
|
||||
|
||||
SignalR hubs process concurrent requests from multiple clients over persistent connections. Using a standard Scoped `DbContext` in SignalR leads to thread conflict exceptions (`InvalidOperationException: A second operation was started on this context instance before a previous operation completed`).
|
||||
|
||||
@@ -119,7 +216,7 @@ SlipItIn solves this by registering EF Core with `AddDbContextFactory<SlipItInDb
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Privacy Isolation Model
|
||||
## 6. Data Privacy Isolation Model
|
||||
|
||||
To prevent players from inspecting opponent cards via network trace analysis, SlipItIn strictly separates public and private data DTOs:
|
||||
|
||||
@@ -127,3 +224,30 @@ To prevent players from inspecting opponent cards via network trace analysis, Sl
|
||||
* **`PlayerHandDto` (Private)**: Direct unicast message sent *only* to the specific player's SignalR `ConnectionId`. Contains card text (`CardId`, `PhraseId`, `Text`, `IsUsed`).
|
||||
|
||||
This architecture guarantees that players cannot see opponent phrase cards, even if they inspect raw WebSocket packets.
|
||||
|
||||
---
|
||||
|
||||
## 7. Guidance for Future Agents & Developers
|
||||
|
||||
When extending or modifying the system architecture:
|
||||
|
||||
- **When to Consult**: Consult this document before adding new real-time SignalR methods, introducing client ViewModels, modifying storage mechanisms, or altering server authentication logic.
|
||||
- **Runtime Invariants**:
|
||||
- `GameHub` methods must validate caller claims via `GetAuthenticatedUserId()` and `ValidatePlayerAccessAsync()`.
|
||||
- Client ViewModels must consume `WeakReferenceMessenger` messages rather than subscribing directly to `ISignalRService` C# events.
|
||||
- JWT tokens must never be persisted in `Preferences`; always use `SecureStorage`.
|
||||
- **Extension Points**:
|
||||
- New real-time server messages: Add method to `GameHub`, add event to `ISignalRService`, add message class in `SlipItIn/Messages/`, update `GameStateService` listener.
|
||||
- New ViewModels: Register as Transient (dialogs/auth) or Singleton (main navigation screens) in `MauiProgram.cs`.
|
||||
- **Primary Source Files**:
|
||||
- `SlipItIn/MauiProgram.cs`
|
||||
- `SlipItIn/Services/SignalRService.cs`
|
||||
- `SlipItIn/Services/GameStateService.cs`
|
||||
- `SlipItIn/Services/LocalStorageService.cs`
|
||||
- `SlipItIn.Server/Hubs/GameHub.cs`
|
||||
- `SlipItIn.Server/Services/GameService.cs`
|
||||
- **Focused Checks**: Build and verify project references across client, server, and shared libraries.
|
||||
- **Minimal Validation Command**:
|
||||
```bash
|
||||
dotnet build SlipItIn.slnx
|
||||
```
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
---
|
||||
type: Domain Model
|
||||
title: Game Domain & State Models
|
||||
description: Detailed domain model and state lifecycles for SlipItIn games, rounds, phrase decks, players, and challenges.
|
||||
tags: [domain, models, state-machine, entity-framework, privacy]
|
||||
description: Detailed domain model and state lifecycles for SlipItIn games, rounds, phrase decks, players, challenges, and client offline action queue.
|
||||
tags: [domain, models, state-machine, entity-framework, privacy, offline-queue]
|
||||
openwiki:
|
||||
roles: [domain]
|
||||
change_kinds: [public-api, lifecycle]
|
||||
source_paths: [SlipItIn.Shared/Models/Game.cs, SlipItIn.Shared/Models/Player.cs, SlipItIn/Models/QueuedGameAction.cs, SlipItIn.Shared/DTOs/GameStateDto.cs]
|
||||
symbols: [Game, Player, PlayerCard, SlipChallenge, QueuedGameAction, GameStateDto, PlayerHandDto]
|
||||
validation_commands: ["dotnet build SlipItIn.Shared/SlipItIn.Shared.csproj"]
|
||||
---
|
||||
|
||||
# Game Domain & State Models
|
||||
|
||||
This page describes the core domain entities, relational schema, state machines, and data transfer objects (DTOs) that form the foundation of the SlipItIn game engine.
|
||||
This page describes the core domain entities, relational schema, state machines, offline action queue models, and data transfer objects (DTOs) that form the foundation of the SlipItIn game engine.
|
||||
|
||||
The domain model [is secured by data privacy models defined in](/openwiki/architecture/overview.md) the system architecture, [defines state lifecycles executed by](/openwiki/workflows/slip-and-challenge.md) real-time game workflows, and [maps domain classes in C# project files indexed in](/openwiki/source-map.md) the source map (`SlipItIn.Shared/Models/` and `SlipItIn.Shared/DTOs/`).
|
||||
The domain model [is secured by data privacy models defined in](/openwiki/architecture/overview.md) the system architecture, [defines state lifecycles executed by](/openwiki/workflows/slip-and-challenge.md) real-time game workflows, and [maps domain classes in C# project files indexed in](/openwiki/source-map.md) the source map (`SlipItIn.Shared/Models/`, `SlipItIn.Shared/DTOs/`, and `SlipItIn/Models/`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Entity Relationship Diagram (ERD)
|
||||
|
||||
The database schema is managed via Entity Framework Core (`SlipItInDbContext`) targeting PostgreSQL.
|
||||
The backend database schema is managed via Entity Framework Core (`SlipItInDbContext`) targeting PostgreSQL.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
@@ -70,19 +76,20 @@ erDiagram
|
||||
ChallengeStatus Status
|
||||
}
|
||||
|
||||
User ||--o{ Game : "hosts"
|
||||
User ||--o{ Player : "participates as"
|
||||
User ||--o{ Phrase : "creates"
|
||||
Game ||--o{ Player : "contains"
|
||||
Game ||--o{ GameRound : "has"
|
||||
Player ||--o{ PlayerCard : "holds"
|
||||
Phrase ||--o{ PlayerCard : "used in"
|
||||
GameRound ||--o{ PlayerCard : "active in"
|
||||
GameRound ||--o{ SlipChallenge : "contains"
|
||||
Player ||--o{ SlipChallenge : "challenges"
|
||||
Player ||--o{ SlipChallenge : "target of"
|
||||
PlayerCard ||--o{ SlipChallenge : "targeted by"
|
||||
User ||--o{ Game : hosts
|
||||
User ||--o{ Player : participates_as
|
||||
User ||--o{ Phrase : creates
|
||||
Game ||--o{ Player : contains
|
||||
Game ||--o{ GameRound : has
|
||||
Player ||--o{ PlayerCard : holds
|
||||
Phrase ||--o{ PlayerCard : used_in
|
||||
GameRound ||--o{ PlayerCard : active_in
|
||||
GameRound ||--o{ SlipChallenge : contains
|
||||
Player ||--o{ SlipChallenge : challenges
|
||||
Player ||--o{ SlipChallenge : target_of
|
||||
PlayerCard ||--o{ SlipChallenge : targeted_by
|
||||
```
|
||||
*Entity Relationship Diagram showing relational database schema for users, games, players, phrases, cards, rounds, and challenges.*
|
||||
|
||||
---
|
||||
|
||||
@@ -125,7 +132,19 @@ Represents an accusation made by one player against another player's submitted s
|
||||
|
||||
---
|
||||
|
||||
## 3. Enumerations & State Lifecycles
|
||||
## 3. Client State & Offline Action Queue Models (`SlipItIn/Models`)
|
||||
|
||||
### `QueuedGameAction`
|
||||
Represents a game action (such as `SubmitSlip` or `ChallengeSlip`) initiated on the client while offline (`!SignalRService.IsConnected`).
|
||||
* **Properties**:
|
||||
* `Method`: Target SignalR hub method name (e.g. `"SubmitSlip"`, `"ChallengeSlip"`).
|
||||
* `ArgumentsJson`: JSON serialized array of method arguments.
|
||||
* `CreatedAtUtc`: Timestamp recording when the action was taken.
|
||||
* **Lifecycle**: Enqueued into `Preferences` via `GameStateService.EnqueueActionAsync()`, replayed sequentially upon reconnection via `GameStateService.FlushQueuedActionsAsync()`, and removed from storage.
|
||||
|
||||
---
|
||||
|
||||
## 4. Enumerations & State Lifecycles
|
||||
|
||||
### Game Status (`GameStatus`)
|
||||
```mermaid
|
||||
@@ -136,6 +155,7 @@ stateDiagram-v2
|
||||
Lobby --> Cancelled: Host cancels
|
||||
InProgress --> Cancelled: Session abort
|
||||
```
|
||||
*State diagram illustrating lifecycle transitions of a game session from lobby creation to completion or cancellation.*
|
||||
|
||||
* **`Lobby`**: Waiting for players to join and set ready status.
|
||||
* **`InProgress`**: Active gameplay with dealt cards and active rounds.
|
||||
@@ -151,6 +171,7 @@ stateDiagram-v2
|
||||
Resolving --> Active: Challenge resolved
|
||||
Active --> Completed: Timer expires / All phrases used
|
||||
```
|
||||
*State diagram illustrating round lifecycles from card dealing to challenge resolution.*
|
||||
|
||||
### Challenge Status (`ChallengeStatus`)
|
||||
* **`Pending`**: Accusation registered, awaiting response from the accused player.
|
||||
@@ -159,13 +180,33 @@ stateDiagram-v2
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Transfer Objects (DTOs) & Data Privacy
|
||||
## 5. Data Transfer Objects (DTOs) & Data Privacy
|
||||
|
||||
To enforce security and data privacy ([Architecture Overview](/openwiki/architecture/overview.md)), the backend exposes decoupled DTOs in `SlipItIn.Shared/DTOs`:
|
||||
|
||||
| DTO | Visibility | Purpose & Content |
|
||||
|---|---|---|
|
||||
| `LoginRequestDto` | Direct REST | Credentials for authentication: `Email`, `Password`. |
|
||||
| `RegisterRequestDto` | Direct REST | Account creation details: `Username`, `Email`, `Password`. |
|
||||
| `AuthResponseDto` | Direct REST | JWT Access Token, Expiration, Username, Email. |
|
||||
| `GameStateDto` | Broadcast (Group) | Public lobby/game status: `GameId`, `LobbyCode`, `Status`, `CurrentRound`, `RoundTimeRemaining`, `Players` (`PlayerInfoDto` array containing `PlayerId`, `Username`, `Score`, `IsReady`, and `CardCount`). **No card text.** |
|
||||
| `PlayerHandDto` | Unicast (Client) | Private hand data sent strictly to the card owner: `PlayerId`, `Cards` (`PlayerCardDto` array containing `CardId`, `PhraseId`, `Text`, `IsUsed`). |
|
||||
| `SlipChallengeDto` | Unicast / Group | Challenge notification: `ChallengeId`, `ChallengingPlayerId`, `TargetPlayerId`, `TargetCardId`, `Status`, `CreatedAt`. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Guidance for Future Agents & Developers
|
||||
|
||||
- **When to Consult**: Consult this page when adding domain attributes, extending DTOs, adding new game state statuses, or modifying offline action serialization.
|
||||
- **Invariants**:
|
||||
- `PlayerCard.IsUsed` transitions from `false` to `true` upon `SubmitSlip` and cannot be reverted without server intervention.
|
||||
- `QueuedGameAction.ArgumentsJson` must store arguments in the exact order and type expected by `GameHub` methods.
|
||||
- **Primary Source Files**:
|
||||
- `SlipItIn.Shared/Models/Game.cs`
|
||||
- `SlipItIn.Shared/Models/PlayerCard.cs`
|
||||
- `SlipItIn.Shared/DTOs/GameStateDto.cs`
|
||||
- `SlipItIn/Models/QueuedGameAction.cs`
|
||||
- **Minimal Validation Command**:
|
||||
```bash
|
||||
dotnet build SlipItIn.Shared/SlipItIn.Shared.csproj
|
||||
```
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Files
|
||||
|
||||
- [Game Domain & State Models](game-mechanics.md) - Detailed domain model and state lifecycles for SlipItIn games, rounds, phrase decks, players, and challenges.
|
||||
- [Game Domain & State Models](game-mechanics.md) - Detailed domain model and state lifecycles for SlipItIn games, rounds, phrase decks, players, challenges, and client offline action queue.
|
||||
|
||||
@@ -4,8 +4,8 @@ okf_version: "0.1"
|
||||
|
||||
# Files
|
||||
|
||||
- [SlipItIn Code Wiki Quickstart](quickstart.md) - Entrypoint for SlipItIn - a real-time multiplayer party game built with .NET 10, MAUI, ASP.NET Core SignalR, EF Core PostgreSQL, and .NET Aspire.
|
||||
- [Source Code Map & Navigation Directory](source-map.md) - Practical navigation guide mapping source files across projects to system domains and responsibilities.
|
||||
- [SlipItIn Code Wiki Quickstart](quickstart.md) - Entrypoint for SlipItIn - a real-time multiplayer party game built with .NET 10, MAUI MVVM, ASP.NET Core SignalR, EF Core PostgreSQL, and .NET Aspire.
|
||||
- [Source Code Map & Navigation Directory](source-map.md) - Practical navigation guide mapping source files across projects to system domains, viewmodels, services, and responsibilities.
|
||||
|
||||
# Directories
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Files
|
||||
|
||||
- [Operations, Environment Setup & Testing Guidance](runbook.md) - Operational guide for launching SlipItIn with .NET Aspire, running EF Core PostgreSQL migrations, configuring JWT secrets, and executing tests.
|
||||
- [Operations, Environment Setup & Testing Guidance](runbook.md) - Operational guide for launching SlipItIn with .NET Aspire, running EF Core PostgreSQL migrations, configuring secrets, and executing client/server verification tests.
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
---
|
||||
type: Runbook
|
||||
title: Operations, Environment Setup & Testing Guidance
|
||||
description: Operational guide for launching SlipItIn with .NET Aspire, running EF Core PostgreSQL migrations, configuring JWT secrets, and executing tests.
|
||||
tags: [operations, runbook, spire, postgresql, migrations, testing]
|
||||
description: Operational guide for launching SlipItIn with .NET Aspire, running EF Core PostgreSQL migrations, configuring secrets, and executing client/server verification tests.
|
||||
tags: [operations, runbook, spire, postgresql, migrations, testing, mvvm, offline-queue]
|
||||
openwiki:
|
||||
roles: [operations, testing]
|
||||
change_kinds: [lifecycle, public-api]
|
||||
source_paths: [SlipItIn.AppHost/AppHost.cs, SlipItIn.Server/Program.cs, SlipItIn/MauiProgram.cs]
|
||||
symbols: [AppHost, Program, MauiProgram]
|
||||
validation_commands: ["dotnet build SlipItIn.slnx"]
|
||||
---
|
||||
|
||||
# Operations, Environment Setup & Testing Guidance
|
||||
|
||||
This runbook provides actionable instructions for local development setup, starting services via .NET Aspire, executing Entity Framework Core migrations, configuring environment keys, and running tests.
|
||||
This runbook provides actionable instructions for local development setup, starting services via .NET Aspire, executing Entity Framework Core migrations, configuring environment keys, running client/server applications, and executing targeted test scenarios.
|
||||
|
||||
This guide [configures environment parameters for](/openwiki/architecture/overview.md) the backend architecture, [manages database migrations for entities in](/openwiki/domain/game-mechanics.md) the domain model, [verifies real-time event flows defined in](/openwiki/workflows/slip-and-challenge.md) the workflow guide, and [references source entrypoints cataloged in](/openwiki/source-map.md) the source map.
|
||||
This guide [configures environment parameters for](/openwiki/architecture/overview.md) the backend and client architecture, [manages database migrations for entities in](/openwiki/domain/game-mechanics.md) the domain model, [verifies real-time event flows defined in](/openwiki/workflows/slip-and-challenge.md) the workflow guide, and [references source entrypoints cataloged in](/openwiki/source-map.md) the source map.
|
||||
|
||||
---
|
||||
|
||||
@@ -81,7 +87,7 @@ dotnet ef database update --project SlipItIn.Server --startup-project SlipItIn.S
|
||||
|
||||
## 4. Testing Guidance & Verification Scenarios
|
||||
|
||||
When developing or extending SlipItIn features, verify the core architecture through targeted test scenarios specified in `Agents/Architecture.md`:
|
||||
When developing or extending SlipItIn features, verify the core architecture through these targeted test scenarios:
|
||||
|
||||
### 1. JWT Authentication & Claims Tests
|
||||
* **Test Objective**: Verify `GameHub` rejects unauthenticated WebSocket connections or missing token query parameters.
|
||||
@@ -91,14 +97,34 @@ When developing or extending SlipItIn features, verify the core architecture thr
|
||||
* **Test Objective**: Verify Player A cannot manipulate Player B's cards or state.
|
||||
* **Verification**: Authenticate as User A and attempt to call `GameHub.SubmitSlip(gameId, playerBId, cardId)`. Verify that `ValidatePlayerAccessAsync` throws `UnauthorizedAccessException` and returns an error response.
|
||||
|
||||
### 3. Concurrency & Race Condition Tests
|
||||
### 3. Client MVVM Decoupled Messaging Tests
|
||||
* **Test Objective**: Confirm `GameStateService` translates SignalR events into `WeakReferenceMessenger` messages without memory leak risk.
|
||||
* **Verification**: Trigger a `GameStateUpdated` event on `SignalRService`. Confirm that `LobbyViewModel` and `GameBoardViewModel` receive `GameStateChangedMessage` and update their observable collections cleanly.
|
||||
|
||||
### 4. Offline Action Queueing & Resync Verification
|
||||
* **Test Objective**: Verify actions taken while disconnected are stored locally and replayed upon reconnection.
|
||||
* **Verification**:
|
||||
1. Disconnect network or stop `SignalRService`.
|
||||
2. Invoke `GameBoardViewModel.SubmitSlipAsync(card)`. Confirm `QueuedGameAction` is written to `Preferences`.
|
||||
3. Re-establish connection. Confirm `GameStateService.ResyncAsync()` calls `RequestGameStateAsync()` and replays the queued action via `SendQueuedActionAsync()`.
|
||||
|
||||
### 5. Dual-Layer Local Storage Verification
|
||||
* **Test Objective**: Confirm sensitive tokens are isolated in `SecureStorage` while non-sensitive state stays in `Preferences`.
|
||||
* **Verification**: Inspect local device storage after login. Verify `auth_token` is stored via platform `SecureStorage` and `game_state` / `queued_actions` are saved in `Preferences`.
|
||||
|
||||
### 6. Concurrency & Race Condition Tests
|
||||
* **Test Objective**: Confirm `IDbContextFactory` handles simultaneous WebSocket calls without thread collision.
|
||||
* **Verification**: Simulate 5 parallel calls to `GameHub.ChallengeSlip()` or `SubmitSlip()` across multiple clients. Verify that all calls complete cleanly without `InvalidOperationException` from DbContext.
|
||||
|
||||
### 4. Data Privacy Isolation Verification
|
||||
* **Test Objective**: Confirm card text is never broadcast in public group messages.
|
||||
* **Verification**: Capture SignalR `PlayerJoined` and `GameStateUpdated` payloads. Inspect JSON content to confirm only `CardCount` is present and no phrase card `Text` is leaked.
|
||||
---
|
||||
|
||||
### 5. False Accusation Penalty Verification
|
||||
* **Test Objective**: Verify penalty card transfer when a challenge is rejected.
|
||||
* **Verification**: Submit a challenge against a valid slip, then call `ResolveChallenge(challengeId, approved: false)`. Verify in the database that `PlayerCard.PlayerId` is reassigned to the challenger's `PlayerId`.
|
||||
## 5. Guidance for Future Agents & Developers
|
||||
|
||||
- **When to Consult**: Refer to this runbook for local environment setup, Aspire startup commands, EF Core migration commands, and test verification procedures.
|
||||
- **Invariants**:
|
||||
- Always run migrations using `SlipItIn.Server` as both project and startup-project.
|
||||
- Test offline queue flushing before submitting client-side real-time changes.
|
||||
- **Minimal Validation Command**:
|
||||
```bash
|
||||
dotnet build SlipItIn.slnx
|
||||
```
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
---
|
||||
type: Overview
|
||||
title: SlipItIn Code Wiki Quickstart
|
||||
description: Entrypoint for SlipItIn - a real-time multiplayer party game built with .NET 10, MAUI, ASP.NET Core SignalR, EF Core PostgreSQL, and .NET Aspire.
|
||||
tags: [quickstart, overview, slipitin, dotnet10, spire]
|
||||
description: Entrypoint for SlipItIn - a real-time multiplayer party game built with .NET 10, MAUI MVVM, ASP.NET Core SignalR, EF Core PostgreSQL, and .NET Aspire.
|
||||
tags: [quickstart, overview, slipitin, dotnet10, spire, maui, mvvm]
|
||||
openwiki:
|
||||
roles: [architecture, domain, workflow]
|
||||
change_kinds: [public-api, lifecycle]
|
||||
source_paths: [SlipItIn/MauiProgram.cs, SlipItIn.Server/Program.cs, SlipItIn.AppHost/AppHost.cs]
|
||||
symbols: [MauiProgram, SignalRService, GameStateService, GameHub, GameService]
|
||||
validation_commands: ["dotnet build SlipItIn.slnx"]
|
||||
---
|
||||
|
||||
# SlipItIn Code Wiki Quickstart
|
||||
|
||||
Welcome to the **SlipItIn** repository wiki. SlipItIn is a real-time multiplayer party game where players receive secret phrase cards and attempt to "slip" those phrases into everyday conversations or text chats without getting caught by other players.
|
||||
|
||||
The solution is built using **.NET 10**, leveraging **ASP.NET Core Web API & SignalR** for the backend engine, **Entity Framework Core (Npgsql / PostgreSQL)** for data persistence, **.NET MAUI** for the cross-platform client app, and **.NET Aspire** for distributed cloud-native orchestration and telemetry.
|
||||
The solution is built using **.NET 10**, leveraging **ASP.NET Core Web API & SignalR** for the backend engine, **Entity Framework Core (Npgsql / PostgreSQL)** for data persistence, **.NET MAUI (MVVM Pattern)** for the cross-platform client app, and **.NET Aspire** for distributed cloud-native orchestration and telemetry.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,47 +27,62 @@ The repository is organized as a multi-project .NET solution (`SlipItIn.slnx`):
|
||||
SlipItIN/
|
||||
├── SlipItIn.AppHost/ # .NET Aspire AppHost orchestrator (pgsql + server + client)
|
||||
├── SlipItIn.Server/ # ASP.NET Core Web API + SignalR Hub + Game Engine
|
||||
├── SlipItIn.Shared/ # Shared Class Library (Models & DTOs)
|
||||
├── SlipItIn.Shared/ # Shared Class Library (Models, DTOs & Enums)
|
||||
├── SlipItIn.ServiceDefaults/ # Aspire OpenTelemetry, Health Checks & Service Discovery
|
||||
└── SlipItIn/ # .NET MAUI Client App (Android, iOS, MacCatalyst, Windows)
|
||||
└── SlipItIn/ # .NET MAUI Client App (MVVM: Views, ViewModels, Services, Messages)
|
||||
```
|
||||
|
||||
The system architecture [orchestrates services with](/openwiki/operations/runbook.md) .NET Aspire and [enforces security and privacy on](/openwiki/domain/game-mechanics.md) all domain entities. For a deep dive into the backend design, JWT security, and concurrency safety, see the [System Architecture & Security Overview](/openwiki/architecture/overview.md).
|
||||
The system architecture [orchestrates services with](/openwiki/operations/runbook.md) .NET Aspire and [enforces security and privacy on](/openwiki/domain/game-mechanics.md) all domain entities. For a deep dive into backend security, SignalR real-time client integration, MVVM architecture, and offline queueing, see the [System Architecture & Security Overview](/openwiki/architecture/overview.md).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
MAUI[SlipItIn .NET MAUI Client] -->|REST / API| Server[SlipItIn.Server ASP.NET Core]
|
||||
MAUI -->|SignalR WebSockets| Hub[GameHub /hubs/game]
|
||||
Server -->|IDbContextFactory| DB[(PostgreSQL Database)]
|
||||
AppHost[.NET Aspire AppHost] -->|Orchestrates| Server
|
||||
subgraph MAUI_Client["SlipItIn .NET MAUI Client"]
|
||||
Views["Views (LoginPage, LobbyPage, GameBoardPage)"] --> ViewModels["ViewModels (LoginVM, LobbyVM, GameBoardVM)"]
|
||||
ViewModels --> GameState["GameStateService"]
|
||||
GameState --> SignalR["SignalRService"]
|
||||
GameState --> LocalStorage["LocalStorageService (SecureStorage & Preferences)"]
|
||||
ViewModels --> ApiService["ApiService (REST Auth)"]
|
||||
end
|
||||
|
||||
ApiService -->|REST /api/auth| AuthCtrl["AuthController"]
|
||||
SignalR -->|WebSockets /hubs/game| Hub["GameHub"]
|
||||
Hub --> Service["GameService"]
|
||||
AuthCtrl --> DB[("PostgreSQL Database")]
|
||||
Service -->|IDbContextFactory| DB
|
||||
AppHost[".NET Aspire AppHost"] -->|Orchestrates| Server["SlipItIn.Server"]
|
||||
AppHost -->|Provisions| DB
|
||||
Server -->|Uses Defaults| ServiceDefaults[SlipItIn.ServiceDefaults]
|
||||
```
|
||||
*System architecture diagram illustrating .NET MAUI MVVM client layer, backend REST/SignalR APIs, and .NET Aspire PostgreSQL orchestration.*
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Game Loop & Mechanics
|
||||
|
||||
1. **Lobby Creation**: Host creates a game lobby with a 6-character code. Players join via code.
|
||||
2. **Card Dealing**: Upon game start, each player receives a private hand of 5 secret phrase cards (`PlayerHandDto`).
|
||||
3. **Phrase Slipping**: During normal conversation, a player speaks or types one of their phrases and clicks **Submit Slip**.
|
||||
4. **Slip Challenge**: Opponents suspecting a fake phrase can issue a **Slip Challenge**.
|
||||
- **Justified Accusation (Approved)**: Target phrase was an invalid slip.
|
||||
- **False Accusation (Rejected)**: Target phrase was legitimate. The accuser receives the card as a penalty (expanding their hand size beyond 5).
|
||||
1. **Authentication & Session**: Player registers or logs in via `LoginPage` / `RegisterPage`. `AuthSessionService` stores the JWT token in `SecureStorage`.
|
||||
2. **Lobby Creation**: Host creates a lobby on `LobbyPage`, generating a 6-character code. Players join using the code.
|
||||
3. **Card Dealing**: Upon game start, each player receives a private hand of 5 secret phrase cards (`PlayerHandDto`) via SignalR unicast.
|
||||
4. **Phrase Slipping**: During normal conversation, a player speaks or types one of their phrases and clicks **Submit Slip** on `GameBoardPage`. If offline, actions queue locally in `QueuedGameAction` and flush automatically upon reconnection.
|
||||
5. **Slip Challenge**: Opponents suspecting a fake phrase can issue a **Slip Challenge**.
|
||||
- **Justified Accusation (Approved)**: Target phrase was an invalid slip. Card remains with accused player.
|
||||
- **False Accusation (Rejected)**: Target phrase was legitimate. The accuser receives the phrase card as a penalty.
|
||||
|
||||
To learn how game events flow across SignalR in real time, inspect the [Slip & Challenge Workflows](/openwiki/workflows/slip-and-challenge.md).
|
||||
|
||||
---
|
||||
|
||||
## 3. Wiki Navigation Map
|
||||
## 3. Task Routing & Navigation Map
|
||||
|
||||
Explore specific documentation sections for technical details:
|
||||
The following task routing table directs future engineers and automated agents to relevant wiki documentation, source entry points, key symbols, and verification commands for common modification scenarios:
|
||||
|
||||
- **[System Architecture & Security Overview](/openwiki/architecture/overview.md)**: Explains .NET Aspire orchestration, JWT bearer authentication, claims validation, `IDbContextFactory` thread safety, and public vs. private data isolation.
|
||||
- **[Game Domain & State Models](/openwiki/domain/game-mechanics.md)**: Details domain entities (`User`, `Game`, `Player`, `Phrase`, `PlayerCard`, `GameRound`, `SlipChallenge`) and their state lifecycles.
|
||||
- **[Slip & Challenge Workflows](/openwiki/workflows/slip-and-challenge.md)**: Details step-by-step game loop execution, real-time SignalR notifications, and penalty rules.
|
||||
- **[Source Code Map](/openwiki/source-map.md)**: Directory and file navigation index mapping repository paths to technical domains.
|
||||
- **[Operations & Runbook](/openwiki/operations/runbook.md)**: Instructions for running the app with Aspire, executing PostgreSQL EF Core migrations, configuration keys, and testing strategies.
|
||||
| Intent / Change Area | Relevant Wiki Page | Source Entry Points | Key Symbols / Types | Focused Checks | Minimal Validation Command |
|
||||
|---|---|---|---|---|---|
|
||||
| **MAUI Client ViewModels & Pages** | [Architecture Overview](/openwiki/architecture/overview.md) | `SlipItIn/ViewModels/`, `SlipItIn/Views/` | `LobbyViewModel`, `GameBoardViewModel`, `LoginViewModel` | Inspect MVVM command handlers and `WeakReferenceMessenger` bindings | `dotnet build SlipItIn/SlipItIn.csproj` |
|
||||
| **Client SignalR & Messaging** | [Slip & Challenge Workflows](/openwiki/workflows/slip-and-challenge.md) | `SlipItIn/Services/SignalRService.cs`, `SlipItIn/Services/GameStateService.cs` | `ISignalRService`, `IGameStateService`, `WeakReferenceMessenger` | Verify auto-reconnect backoff and message dispatching | `dotnet build SlipItIn/SlipItIn.csproj` |
|
||||
| **Offline Action Queue & Resync** | [Slip & Challenge Workflows](/openwiki/workflows/slip-and-challenge.md) | `SlipItIn/Services/GameStateService.cs`, `SlipItIn/Models/QueuedGameAction.cs` | `QueuedGameAction`, `EnqueueActionAsync`, `FlushQueuedActionsAsync` | Verify offline queueing in `Preferences` and flush on reconnect | `dotnet build SlipItIn/SlipItIn.csproj` |
|
||||
| **Local Storage & Auth Session** | [Architecture Overview](/openwiki/architecture/overview.md) | `SlipItIn/Services/LocalStorageService.cs`, `SlipItIn/Services/AuthSessionService.cs` | `ILocalStorageService`, `IAuthSessionService`, `SecureStorage` | Confirm JWT stored in `SecureStorage` and state in `Preferences` | `dotnet build SlipItIn/SlipItIn.csproj` |
|
||||
| **Backend Game Engine & Hub** | [Architecture Overview](/openwiki/architecture/overview.md) | `SlipItIn.Server/Hubs/GameHub.cs`, `SlipItIn.Server/Services/GameService.cs` | `GameHub`, `GameService`, `IDbContextFactory` | Test JWT claim extraction and EF Core DbContext session isolation | `dotnet build SlipItIn.Server/SlipItIn.Server.csproj` |
|
||||
| **Domain Models & DTOs** | [Game Domain & State Models](/openwiki/domain/game-mechanics.md) | `SlipItIn.Shared/Models/`, `SlipItIn.Shared/DTOs/` | `GameStateDto`, `PlayerHandDto`, `SlipChallengeDto`, `QueuedGameAction` | Confirm card privacy isolation between public state and private hand | `dotnet build SlipItIn.Shared/SlipItIn.Shared.csproj` |
|
||||
| **Aspire & Infrastructure Setup** | [Operations & Runbook](/openwiki/operations/runbook.md) | `SlipItIn.AppHost/AppHost.cs`, `SlipItIn.ServiceDefaults/` | `AppHost`, `AddServiceDefaults`, `AddPostgres` | Validate Aspire service references and PostgreSQL container creation | `dotnet run --project SlipItIn.AppHost/SlipItIn.AppHost.csproj` |
|
||||
|
||||
---
|
||||
|
||||
@@ -72,6 +93,8 @@ When modifying this repository, strictly adhere to these core rules:
|
||||
1. **IDbContextFactory Thread Safety**: Never inject a scoped `SlipItInDbContext` into SignalR hubs or singleton services. Always use `IDbContextFactory<SlipItInDbContext>.CreateDbContext()` to prevent DbContext concurrency exceptions during concurrent WebSocket calls ([Architecture Overview](/openwiki/architecture/overview.md)).
|
||||
2. **Data Privacy Isolation**: Do not leak card text into public DTOs. Public game state must be broadcast using `GameStateDto` (card counts only), while private cards are dispatched strictly via `PlayerHandDto` to individual client connections ([Domain Mechanics](/openwiki/domain/game-mechanics.md)).
|
||||
3. **Server-Side Validation**: SignalR client calls represent intent ("I want to challenge X"). The server MUST re-verify JWT claims, player game membership, card ownership, and round status inside `GameHub.cs` and `GameService.cs` before mutating state ([Slip & Challenge Workflows](/openwiki/workflows/slip-and-challenge.md)).
|
||||
4. **Decoupled Client Messaging**: MAUI ViewModels MUST NOT consume `ISignalRService` events directly for UI updates. Always route events through `IGameStateService` via `WeakReferenceMessenger` messages (`GameStateChangedMessage`, `PlayerHandChangedMessage`, `ChallengeReceivedMessage`) to prevent memory leaks and dangling subscriptions ([Architecture Overview](/openwiki/architecture/overview.md)).
|
||||
5. **Dual-Layer Local Storage**: Always store sensitive credentials (JWT access tokens) in platform `SecureStorage`. Non-sensitive UI states, cached game data, and offline action queues MUST be persisted in `Preferences` ([Architecture Overview](/openwiki/architecture/overview.md)).
|
||||
|
||||
---
|
||||
|
||||
@@ -79,6 +102,4 @@ When modifying this repository, strictly adhere to these core rules:
|
||||
|
||||
The following features and components are specified in specification documents (`Agents/ProjectPlan.md` and `Agents/Architecture.md`) and backlogged for upcoming development iterations:
|
||||
|
||||
- **MAUI Client Services & ViewModels**: Implement `ApiService`, `SignalRService`, `GameStateService`, `LobbyViewModel`, and `GameBoardViewModel` under `SlipItIn/` using `CommunityToolkit.Mvvm` and `WeakReferenceMessenger`. (Anchor: `SlipItIn/`, pending client phase 3 completion).
|
||||
- **Offline Action Queue & Auto-Resync**: Implement exponential backoff reconnect logic (0s, 2s, 10s, 30s) and queued action execution upon app resume in MAUI client. (Anchor: `SlipItIn/Services/`, deferred until MAUI service layer setup).
|
||||
- **Timer Engine for Slip Rounds**: Background timer service on server enforcing round duration limits (30-60s) with automated round completion notifications. (Anchor: `SlipItIn.Server/Services/`, pending phase 2b refinement).
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
---
|
||||
type: Reference
|
||||
title: Source Code Map & Navigation Directory
|
||||
description: Practical navigation guide mapping source files across projects to system domains and responsibilities.
|
||||
tags: [source-map, navigation, directory, projects]
|
||||
description: Practical navigation guide mapping source files across projects to system domains, viewmodels, services, and responsibilities.
|
||||
tags: [source-map, navigation, directory, projects, maui, mvvm]
|
||||
openwiki:
|
||||
roles: [repository]
|
||||
change_kinds: [public-api]
|
||||
source_paths: [SlipItIn/MauiProgram.cs, SlipItIn.Server/Program.cs, SlipItIn.AppHost/AppHost.cs]
|
||||
symbols: [MauiProgram, Program, AppHost]
|
||||
validation_commands: ["dotnet build SlipItIn.slnx"]
|
||||
---
|
||||
|
||||
# Source Code Map & Navigation Directory
|
||||
@@ -19,9 +25,9 @@ This navigation map [indexes backend architecture files described in](/openwiki/
|
||||
SlipItIn.slnx
|
||||
├── SlipItIn.AppHost/ # Aspire distributed orchestrator
|
||||
├── SlipItIn.Server/ # Web API & SignalR real-time server
|
||||
├── SlipItIn.Shared/ # Shared models & data transfer objects
|
||||
├── SlipItIn.Shared/ # Shared models, DTOs & enums
|
||||
├── SlipItIn.ServiceDefaults/ # Aspire OpenTelemetry & health checks
|
||||
├── SlipItIn/ # .NET MAUI multi-platform client
|
||||
├── SlipItIn/ # .NET MAUI multi-platform client (MVVM)
|
||||
└── Agents/ # Architecture & planning briefs
|
||||
```
|
||||
|
||||
@@ -41,7 +47,7 @@ SlipItIn.slnx
|
||||
- **`Controllers/AuthController.cs`**: REST API controller providing `/api/auth/register`, `/api/auth/login`, and `/api/auth/me`. Handles BCrypt password hashing and JWT token generation.
|
||||
- **`Hubs/GameHub.cs`**: SignalR hub mapped to `/hubs/game`. Performs JWT claim extraction (`GetAuthenticatedUserId`), player/host authorization checks (`ValidatePlayerAccessAsync`, `ValidateHostAccessAsync`), connection ID tracking, and real-time event broadcasting.
|
||||
- **`Services/IGameService.cs`**: Contract interface defining backend game operations.
|
||||
- **`Services/GameService.cs`**: Core engine implementation. Handles game creation, player joining, card dealing from active phrases, slip submission, challenge creation, penalty resolution, and state serialization (`GetGameStateAsync`, `GetPlayerHandAsync`).
|
||||
- **`Services/GameService.cs`**: Core engine implementation. Handles game creation, player joining, card dealing from active phrases, slip submission, challenge creation, penalty resolution, and state serialization (`GetGameStateAsync`, `GetPlayerHandAsync`). Adds `hostPlayer` directly to `game.Players` before `context.Games.Add(game)` for EF Core navigation safety.
|
||||
- **`Data/SlipItInDbContext.cs`**: Entity Framework Core DbContext mapping `Users`, `Games`, `Players`, `Phrases`, `PlayerCards`, `GameRounds`, and `SlipChallenges` to PostgreSQL tables.
|
||||
- **`Migrations/`**: Auto-generated EF Core migration snapshots (`20260723193505_InitialCreate.cs`).
|
||||
- **`appsettings.json` & `appsettings.Development.json`**: JWT secret keys, issuer/audience defaults, and database connection strings.
|
||||
@@ -58,12 +64,38 @@ SlipItIn.slnx
|
||||
- **`DTOs/GameStateDto.cs`**: DTOs for public state (`GameStateDto`, `PlayerInfoDto`), private hand state (`PlayerHandDto`, `PlayerCardDto`), and challenge alerts (`SlipChallengeDto`).
|
||||
|
||||
### `Cross-Platform MAUI Client` (`SlipItIn`)
|
||||
- **`MauiProgram.cs`**: Client builder configuring MAUI app shell, fonts, and logging debug extensions.
|
||||
- **`MauiProgram.cs`**: Client app builder. Configures dependency injection for ViewModels and Services, fonts, debug logging, and sets up `ServiceHelper.Services`.
|
||||
- **`App.xaml` & `App.xaml.cs`**: Root MAUI application class.
|
||||
- **`AppShell.xaml` & `AppShell.xaml.cs`**: AppShell routing container.
|
||||
- **`MainPage.xaml` & `MainPage.xaml.cs`**: Initial entry view.
|
||||
- **`AppShell.xaml` & `AppShell.xaml.cs`**: Shell navigation container routing between `LoginPage`, `RegisterPage`, `LobbyPage`, and `GameBoardPage`.
|
||||
- **`Infrastructure/ServiceHelper.cs`**: Static service locator bridge for runtime DI resolution.
|
||||
- **`Services/`**:
|
||||
- `SignalRService.cs` (`ISignalRService`): WebSocket SignalR client, hub method invoker, and auto-reconnect engine.
|
||||
- `GameStateService.cs` (`IGameStateService`): Central game state holder, messenger bridge (`WeakReferenceMessenger`), and offline action queue manager.
|
||||
- `LocalStorageService.cs` (`ILocalStorageService`): Dual-layer local persistence (`SecureStorage` for tokens, `Preferences` for game state/offline queue).
|
||||
- `AuthSessionService.cs` (`IAuthSessionService`): Authenticated user session state manager.
|
||||
- `ApiService.cs` (`IApiService`): REST HTTP client for authentication endpoints.
|
||||
- `AppConfigurationService.cs` (`IAppConfigurationService`): Configuration provider for API base URL and SignalR Hub URL.
|
||||
- **`ViewModels/`**:
|
||||
- `BaseViewModel.cs`: Abstract base ViewModel providing `IsBusy`, `StatusMessage`, and error handling helper `RunSafeAsync`.
|
||||
- `LoginViewModel.cs`: Handles email/password authentication and navigation to `LobbyPage`.
|
||||
- `RegisterViewModel.cs`: Handles user registration.
|
||||
- `LobbyViewModel.cs`: Manages lobby creation, joining, player readiness, and game start. Implements `IRecipient` interfaces for real-time messages.
|
||||
- `GameBoardViewModel.cs`: Manages game board state, player cards, slip submission, challenging, challenge resolution, and offline queueing.
|
||||
- **`Views/`**:
|
||||
- `LoginPage.xaml` / `.cs`: XAML login UI view.
|
||||
- `RegisterPage.xaml` / `.cs`: XAML registration UI view.
|
||||
- `LobbyPage.xaml` / `.cs`: XAML lobby UI view displaying lobby code and player list.
|
||||
- `GameBoardPage.xaml` / `.cs`: XAML game board UI view displaying cards and player list.
|
||||
- `ChallengeNotificationOverlay.xaml` / `.cs`: Reusable overlay view for challenge alerts.
|
||||
- **`Messages/`**:
|
||||
- `LobbyCreatedMessage.cs`, `GameStateChangedMessage.cs`, `GameStartedMessage.cs`, `PlayerHandChangedMessage.cs`, `ChallengeReceivedMessage.cs`, `ErrorOccurredMessage.cs`, `ConnectionStateChangedMessage.cs`: Strongly-typed `CommunityToolkit.Mvvm.Messaging` payloads.
|
||||
- **`Models/`**:
|
||||
- `QueuedGameAction.cs`: Data model for offline game action queueing.
|
||||
- **`Platforms/`**: Platform-specific entry points for Android, iOS, MacCatalyst, and Windows.
|
||||
|
||||
### `Documentation & Specifications` (`Agents/`)
|
||||
- **`Agents/Architecture.md`**: Specification document defining Phase 2b backend security (JWT validation, `IDbContextFactory`, privacy DTOs) and Phase 3b MAUI stability patterns (`WeakReferenceMessenger`, auto-reconnect, dual-layer storage).
|
||||
- **`Agents/ProjectPlan.md`**: Project plan breakdown covering phases 1 through 4.
|
||||
- **`Agents/Architecture.md`**: Specification document defining backend security and client MVVM/stability patterns.
|
||||
- **`Agents/ProjectPlan.md`**: Project plan breakdown covering development phases.
|
||||
|
||||
### `Automation & CI/CD` (`.github/workflows/`)
|
||||
- **`.github/workflows/openwiki-update.yml`**: Scheduled GitHub Actions workflow executing `openwiki code --update` via Gemini Flash (`gemini-3.6-flash`) to keep repository documentation in sync with source changes.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Files
|
||||
|
||||
- [Slip & Challenge Workflows](slip-and-challenge.md) - Real-time game loops covering lobby creation, card dealing, phrase slipping, slip challenging, and challenge resolution.
|
||||
- [Slip & Challenge Workflows](slip-and-challenge.md) - Real-time game loops covering authentication, lobby creation, card dealing, phrase slipping, slip challenging, challenge resolution, and offline action queuing.
|
||||
|
||||
@@ -1,121 +1,175 @@
|
||||
---
|
||||
type: Workflow
|
||||
title: Slip & Challenge Workflows
|
||||
description: Real-time game loops covering lobby creation, card dealing, phrase slipping, slip challenging, and challenge resolution.
|
||||
tags: [workflow, game-loop, signalr, slip-mechanic, real-time]
|
||||
description: Real-time game loops covering authentication, lobby creation, card dealing, phrase slipping, slip challenging, challenge resolution, and offline action queuing.
|
||||
tags: [workflow, game-loop, signalr, slip-mechanic, real-time, offline-resync]
|
||||
openwiki:
|
||||
roles: [workflow]
|
||||
change_kinds: [public-api, lifecycle]
|
||||
source_paths: [SlipItIn/ViewModels/GameBoardViewModel.cs, SlipItIn/Services/GameStateService.cs, SlipItIn/Services/SignalRService.cs, SlipItIn.Server/Hubs/GameHub.cs]
|
||||
symbols: [LobbyViewModel, GameBoardViewModel, SignalRService, GameStateService, GameHub]
|
||||
validation_commands: ["dotnet build SlipItIn.slnx"]
|
||||
---
|
||||
|
||||
# Slip & Challenge Workflows
|
||||
|
||||
This document outlines the core real-time game workflows in SlipItIn, detailing how SignalR events, backend service operations, database updates, and client state notifications interact.
|
||||
This document outlines the core real-time game workflows in SlipItIn, detailing how client MVVM ViewModels, `WeakReferenceMessenger`, backend SignalR hubs, database services, and offline action queues interact.
|
||||
|
||||
These workflows [execute state transitions on](/openwiki/domain/game-mechanics.md) domain entities, [invoke real-time methods in](/openwiki/architecture/overview.md) the ASP.NET Core `GameHub`, [are implemented across backend services mapped in](/openwiki/source-map.md) the source map, and [are verified using tests described in](/openwiki/operations/runbook.md) the operations runbook.
|
||||
These workflows [execute state transitions on](/openwiki/domain/game-mechanics.md) domain entities, [invoke real-time methods in](/openwiki/architecture/overview.md) the ASP.NET Core `GameHub` and client `SignalRService`, [are implemented across backend and client services mapped in](/openwiki/source-map.md) the source map, and [are verified using tests described in](/openwiki/operations/runbook.md) the operations runbook.
|
||||
|
||||
---
|
||||
|
||||
## 1. Game Setup & Lobby Workflow
|
||||
|
||||
```
|
||||
[Host] CreateLobby() ──> Generate 6-Char LobbyCode ──> Add to SignalR Group
|
||||
│
|
||||
[Player] JoinLobby() ──> Verify MaxPlayers & Status ───────────┤
|
||||
│
|
||||
[Player] PlayerReady() ──> Set IsReady = true ─────────────────┼──> Broadcast GameStateUpdated
|
||||
│
|
||||
[Host] StartGame() ──> Deal 5 Random Cards Per Player ─────────┴──> Unicast PlayerHandUpdated
|
||||
[Client App] LoginVM.LoginAsync() ──> REST Auth ──> Save Token in SecureStorage ──> SignalR.ConnectAsync()
|
||||
│
|
||||
[Host] LobbyVM.CreateLobbyAsync() ──> SignalR.CreateLobbyAsync() ──> Server Generates Code ──┤
|
||||
│
|
||||
[Player] LobbyVM.JoinLobbyAsync() ──> SignalR.JoinLobbyAsync(code) ──────────────────────────┤
|
||||
│
|
||||
[Player] LobbyVM.SetReadyAsync() ──> SignalR.PlayerReadyAsync() ─────────────────────────────┼──> Broadcast GameStateUpdated
|
||||
│
|
||||
[Host] LobbyVM.StartGameAsync() ──> SignalR.StartGameAsync() ──> Deal 5 Cards / Player ──────┴──> Unicast PlayerHandUpdated
|
||||
```
|
||||
|
||||
1. **Lobby Creation**: Host calls `GameHub.CreateLobby()`. `GameService.CreateGameAsync()` creates a `Game` record (`Status = Lobby`), generates a random 6-character `LobbyCode`, adds the host as the first `Player`, and registers the host's WebSocket `ConnectionId`. Caller receives `LobbyCreated`.
|
||||
2. **Joining Lobby**: Opponents invoke `GameHub.JoinLobby(lobbyCode)`. `GameService.JoinGameAsync()` validates that the game exists, has space (`Players.Count < MaxPlayers`), and is in `Lobby` status. The player is assigned a `Player` entry, added to the SignalR group `lobbyCode`, and `PlayerJoined` (`GameStateDto`) is broadcast to all participants.
|
||||
3. **Player Ready**: Players invoke `GameHub.PlayerReady(gameId, playerId)`. `GameHub` checks `ValidatePlayerAccessAsync` and broadcasts `GameStateUpdated`.
|
||||
4. **Game Start & Card Dealing**: The host invokes `GameHub.StartGame(gameId)`. `GameHub` verifies host authorization via `ValidateHostAccessAsync(gameId, authUserId)`.
|
||||
1. **Authentication & Session Setup**: Player logs in via `LoginPage` (`LoginViewModel.LoginAsync()`). `ApiService` posts credentials to `/api/auth/login`. `AuthSessionService` persists the token to `SecureStorage`. `SignalRService.ConnectAsync(token)` opens a WebSocket connection to `/hubs/game`.
|
||||
2. **Lobby Creation**: Host invokes `LobbyViewModel.CreateLobbyAsync()`. `SignalRService.CreateLobbyAsync()` triggers `GameHub.CreateLobby()`. `GameService.CreateGameAsync()` creates a `Game` record (`Status = Lobby`), generates a random 6-character `LobbyCode`, adds host to `game.Players`, and registers host's `ConnectionId`. Caller receives `LobbyCreated` message via `WeakReferenceMessenger`.
|
||||
3. **Joining Lobby**: Opponents call `LobbyViewModel.JoinLobbyAsync()`. `SignalRService.JoinLobbyAsync(code)` validates game status and capacity. `GameService.JoinGameAsync()` assigns player to game, registers `ConnectionId`, adds player to SignalR group `lobbyCode`, and broadcasts `PlayerJoined` (`GameStateDto`) to all participants.
|
||||
4. **Player Ready**: Players invoke `LobbyViewModel.SetReadyAsync()`. `GameHub.PlayerReady()` verifies access and broadcasts `GameStateUpdated`.
|
||||
5. **Game Start & Card Dealing**: The host invokes `LobbyViewModel.StartGameAsync()`.
|
||||
- `GameHub.StartGame()` verifies host identity (`ValidateHostAccessAsync`).
|
||||
- `GameService.StartGameAsync()` transitions `GameStatus` to `InProgress` and creates `GameRound` 1 (`RoundStatus.Active`).
|
||||
- `GameService.DealCardsAsync()` fetches active phrases, randomly selects 5 phrases per player, creates `PlayerCard` entries, and saves them to PostgreSQL.
|
||||
- SignalR broadcasts `GameStarted` to the group.
|
||||
- `GameService.DealCardsAsync()` selects 5 random phrases per player, creates `PlayerCard` records, and saves them to PostgreSQL.
|
||||
- SignalR broadcasts `GameStarted` to the group (triggering client navigation to `GameBoardPage`).
|
||||
- SignalR sends private `PlayerHandUpdated` notifications (`PlayerHandDto`) individually to each player's `ConnectionId`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Phrase Slipping Workflow
|
||||
## 2. Phrase Slipping & Offline Queueing Workflow
|
||||
|
||||
During real-life conversation or chat, a player speaks or types one of their secret phrases and marks it as used in the app:
|
||||
|
||||
1. **Submission**: Player A calls `GameHub.SubmitSlip(gameId, playerId, cardId)`.
|
||||
2. **Access Control**: `GameHub` executes `ValidatePlayerAccessAsync(playerId, userId)` to verify that Player A owns the specified `Player` account.
|
||||
3. **Engine Execution**: `GameService.SubmitSlipAsync()` loads the `PlayerCard`, validates card ownership (`card.PlayerId == playerId`), and sets `card.IsUsed = true`.
|
||||
4. **Notification**: `GameHub` broadcasts `SlipSubmitted` (`{ PlayerId, CardId, Success }`) to the SignalR lobby group.
|
||||
1. **User Action**: Player clicks **Submit Slip** on `GameBoardPage` (`GameBoardViewModel.SubmitSlipAsync(card)`).
|
||||
2. **Online / Offline Branching**:
|
||||
- **If Connected (`_signalR.IsConnected`)**: Calls `SignalRService.SubmitSlipAsync(gameId, playerId, cardId)` directly. `GameHub.SubmitSlip()` validates ownership and updates `PlayerCard.IsUsed = true`. `GameHub` broadcasts `SlipSubmitted` to the lobby group.
|
||||
- **If Offline (`!_signalR.IsConnected`)**: Calls `GameStateService.EnqueueActionAsync("SubmitSlip", gameId, playerId, cardId)`. Action is serialized into `QueuedGameAction` and saved in `Preferences`. UI updates status to `"Offline: Aktion wurde zwischengespeichert."`
|
||||
|
||||
---
|
||||
|
||||
## 3. Slip Challenge & Resolution Workflow
|
||||
|
||||
If another player suspects that a submitted phrase or recent conversation statement was an invalid slip, they can challenge the slipper.
|
||||
If an opponent suspects that a submitted phrase or recent conversation statement was an invalid slip, they can challenge the slipper.
|
||||
|
||||
### Step 1: Challenge Initiation
|
||||
1. Player B (Challenger) calls `GameHub.ChallengeSlip(gameId, challengingPlayerId, targetCardId)`.
|
||||
2. `GameHub` verifies Player B's identity (`ValidatePlayerAccessAsync`).
|
||||
3. `GameService.CreateChallengeAsync()` creates a `SlipChallenge` entity with `ChallengeStatus.Pending`.
|
||||
4. `GameHub` broadcasts `SlipChallenged` (`SlipChallengeDto`) to the lobby group.
|
||||
5. `GameHub` sends a targeted `ChallengeReceived` message directly to Player A's `ConnectionId`.
|
||||
1. Challenger clicks **Challenge** on `GameBoardPage` (`GameBoardViewModel.ChallengeCardAsync(card)`).
|
||||
2. If online, calls `SignalRService.ChallengeSlipAsync(gameId, playerId, cardId)`.
|
||||
3. `GameHub.ChallengeSlip()` verifies challenger identity (`ValidatePlayerAccessAsync`).
|
||||
4. `GameService.CreateChallengeAsync()` creates a `SlipChallenge` entity with `ChallengeStatus.Pending`.
|
||||
5. `GameHub` broadcasts `SlipChallenged` (`SlipChallengeDto`) to the group and unicasts `ChallengeReceived` directly to target player's `ConnectionId`.
|
||||
6. Client target player sees `ChallengeNotificationOverlay` on `GameBoardPage`.
|
||||
|
||||
### Step 2: Challenge Resolution
|
||||
Player A (the accused) responds by admitting or denying the false slip:
|
||||
The accused player responds via `ChallengeNotificationOverlay`:
|
||||
|
||||
1. Player A calls `GameHub.ResolveChallenge(challengeId, approved)`.
|
||||
2. `GameHub` executes `ValidateChallengeTargetAccessAsync(challengeId, authUserId)` to guarantee that *only* the accused player can resolve the challenge.
|
||||
3. `GameService.ResolveChallengeAsync()` executes penalty logic:
|
||||
- **`approved = true` (Justified Accusation / Legitimate Catch)**: The accused admits the phrase was a fake slip. `SlipChallenge.Status` is set to `Approved`. The card remains with the accused player.
|
||||
- **`approved = false` (False Accusation / Wrong Penalty)**: The accused denies the charge (the slip was valid). `SlipChallenge.Status` is set to `Rejected`. As a penalty for making a false accusation, the phrase card is reassigned to the challenger: `targetCard.PlayerId = challenge.ChallengingPlayerId`. The challenger now holds >5 cards in their hand.
|
||||
4. `GameHub` broadcasts `ChallengeResolved` to the lobby group.
|
||||
|
||||
### Real-time Sequence Diagram
|
||||
1. Accused player calls `GameBoardViewModel.ResolveChallengeAsync(challengeId, approved)`.
|
||||
2. `SignalRService.ResolveChallengeAsync(challengeId, approved)` calls `GameHub.ResolveChallenge()`.
|
||||
3. `GameHub` executes `ValidateChallengeTargetAccessAsync(challengeId, authUserId)` to guarantee *only* the accused player can resolve the challenge.
|
||||
4. `GameService.ResolveChallengeAsync()` executes penalty logic:
|
||||
- **`approved = true` (Justified Accusation / Legitimate Catch)**: The accused admits the phrase was a fake slip. `SlipChallenge.Status` is set to `Approved`. The card remains with accused player.
|
||||
- **`approved = false` (False Accusation / Wrong Penalty)**: The accused denies the charge. `SlipChallenge.Status` is set to `Rejected`. As a penalty for making a false accusation, the phrase card is reassigned to the challenger: `targetCard.PlayerId = challenge.ChallengingPlayerId`.
|
||||
5. `GameHub` broadcasts `ChallengeResolved` to the group.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Slipper as Player A (Accused)
|
||||
actor Challenger as Player B (Challenger)
|
||||
actor Slipper as Accused Player A
|
||||
actor Challenger as Challenger Player B
|
||||
participant GameBoardVM as GameBoardViewModel
|
||||
participant Hub as GameHub
|
||||
participant Service as GameService
|
||||
participant DB as SlipItInDbContext
|
||||
|
||||
Note over Slipper, Challenger: Active Gameplay
|
||||
Slipper->>Hub: SubmitSlip(gameId, playerAId, cardId)
|
||||
Hub->>Service: SubmitSlipAsync(gameId, playerAId, cardId)
|
||||
Slipper->>GameBoardVM: SubmitSlipAsync(card)
|
||||
GameBoardVM->>Hub: SubmitSlip(gameId, playerAId, cardId)
|
||||
Hub->>Service: SubmitSlipAsync()
|
||||
Service->>DB: Set PlayerCard.IsUsed = true
|
||||
Hub-->>Challenger: Broadcast "SlipSubmitted"
|
||||
Hub-->>Challenger: Broadcast SlipSubmitted
|
||||
|
||||
Note over Challenger: Suspects invalid phrase
|
||||
Challenger->>Hub: ChallengeSlip(gameId, playerBId, targetCardId)
|
||||
Challenger->>GameBoardVM: ChallengeCardAsync(card)
|
||||
GameBoardVM->>Hub: ChallengeSlip(gameId, playerBId, cardId)
|
||||
Hub->>Service: CreateChallengeAsync()
|
||||
Service->>DB: Insert SlipChallenge (Status = Pending)
|
||||
Hub-->>Challenger: Broadcast "SlipChallenged" (SlipChallengeDto)
|
||||
Hub-->>Slipper: Unicast "ChallengeReceived" to ConnectionId
|
||||
Hub-->>Challenger: Broadcast SlipChallenged
|
||||
Hub-->>Slipper: Unicast ChallengeReceived
|
||||
|
||||
Note over Slipper: Accused resolves accusation
|
||||
Slipper->>Hub: ResolveChallenge(challengeId, approved)
|
||||
Note over Slipper: Notification Overlay Displays
|
||||
Slipper->>GameBoardVM: ResolveChallengeAsync(challengeId, approved)
|
||||
GameBoardVM->>Hub: ResolveChallenge(challengeId, approved)
|
||||
Hub->>Hub: ValidateChallengeTargetAccessAsync()
|
||||
Hub->>Service: ResolveChallengeAsync(challengeId, approved)
|
||||
Hub->>Service: ResolveChallengeAsync()
|
||||
alt approved == false (False Accusation)
|
||||
Service->>DB: Update targetCard.PlayerId = PlayerBId (Penalty)
|
||||
Service->>DB: Reassign card.PlayerId = PlayerBId
|
||||
Service->>DB: Set Challenge.Status = Rejected
|
||||
else approved == true (Justified Catch)
|
||||
Service->>DB: Set Challenge.Status = Approved
|
||||
end
|
||||
Hub-->>Challenger: Broadcast "ChallengeResolved"
|
||||
Hub-->>Challenger: Broadcast ChallengeResolved
|
||||
```
|
||||
*Sequence diagram showing phrase submission, challenge initiation, target notification, and penalty resolution.*
|
||||
|
||||
---
|
||||
|
||||
## 4. SignalR Hub Event Reference
|
||||
## 4. Offline Action Queueing & Reconnection Resync Workflow
|
||||
|
||||
Summary of server-to-client events emitted by `GameHub.cs`:
|
||||
When network connection is temporarily interrupted during gameplay, SlipItIn ensures action durability and smooth state synchronization:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor User as Player
|
||||
participant GameBoardVM as GameBoardViewModel
|
||||
participant GameStateSvc as GameStateService
|
||||
participant LocalStorage as LocalStorageService
|
||||
participant SignalRSvc as SignalRService
|
||||
participant Hub as GameHub
|
||||
|
||||
Note over User, SignalRSvc: Network Disconnected (Offline)
|
||||
User->>GameBoardVM: SubmitSlipAsync(card)
|
||||
GameBoardVM->>GameStateSvc: EnqueueActionAsync("SubmitSlip", args)
|
||||
GameStateSvc->>LocalStorage: SaveQueuedActionsAsync(queuedList)
|
||||
LocalStorage-->>GameBoardVM: Saved to Preferences
|
||||
|
||||
Note over SignalRSvc: Network Restored
|
||||
SignalRSvc->>Hub: Automatic Reconnect (Exponential Backoff)
|
||||
SignalRSvc->>GameStateSvc: ConnectionStateChanged(true)
|
||||
GameStateSvc->>GameStateSvc: ResyncAsync()
|
||||
GameStateSvc->>SignalRSvc: RequestGameStateAsync(gameId)
|
||||
SignalRSvc->>Hub: RequestGameState(gameId)
|
||||
Hub-->>GameStateSvc: GameStateUpdated (Fresh DTO)
|
||||
|
||||
GameStateSvc->>GameStateSvc: FlushQueuedActionsAsync()
|
||||
loop For each QueuedGameAction
|
||||
GameStateSvc->>SignalRSvc: SendQueuedActionAsync(action)
|
||||
SignalRSvc->>Hub: InvokeCoreAsync(action.Method, action.Args)
|
||||
end
|
||||
GameStateSvc->>LocalStorage: ClearQueuedActionsAsync()
|
||||
```
|
||||
*Sequence diagram showing offline action queueing in local preferences, automatic reconnection, state request, and queued action replay.*
|
||||
|
||||
---
|
||||
|
||||
## 5. SignalR Hub Event Reference
|
||||
|
||||
Summary of server-to-client events emitted by `GameHub.cs` and handled by `SignalRService`:
|
||||
|
||||
| Event Name | Scope | Payload | Trigger |
|
||||
|---|---|---|---|
|
||||
| `LobbyCreated` | Caller | `{ GameId, LobbyCode }` | Host calls `CreateLobby()` |
|
||||
| `PlayerJoined` | Group | `GameStateDto` | Player calls `JoinLobby()` |
|
||||
| `GameStateUpdated` | Group | `GameStateDto` | Player calls `PlayerReady()` |
|
||||
| `GameStateUpdated` | Group | `GameStateDto` | Player calls `PlayerReady()` or `RequestGameState()` |
|
||||
| `GameStarted` | Group | `{ GameId }` | Host calls `StartGame()` |
|
||||
| `PlayerHandUpdated` | Unicast | `PlayerHandDto` | Hand dealt or updated |
|
||||
| `SlipSubmitted` | Group | `{ PlayerId, CardId, Success }` | Player calls `SubmitSlip()` |
|
||||
@@ -123,3 +177,21 @@ Summary of server-to-client events emitted by `GameHub.cs`:
|
||||
| `ChallengeReceived` | Unicast | `SlipChallengeDto` | Direct alert to target player |
|
||||
| `ChallengeResolved` | Group | `SlipChallengeDto` | Target calls `ResolveChallenge()` |
|
||||
| `Error` | Caller | `{ Message }` | Any authorization or engine exception |
|
||||
|
||||
---
|
||||
|
||||
## 6. Guidance for Future Agents & Developers
|
||||
|
||||
- **When to Consult**: Refer to this document when adding new game interactions, modifying UI challenge flows, changing reconnection strategies, or replaying offline actions.
|
||||
- **Invariants**:
|
||||
- `FlushQueuedActionsAsync()` MUST execute in strictly sequential order based on `CreatedAtUtc`.
|
||||
- `ResolveChallenge` MUST throw `UnauthorizedAccessException` if invoked by anyone other than the target player.
|
||||
- **Primary Source Files**:
|
||||
- `SlipItIn/ViewModels/GameBoardViewModel.cs`
|
||||
- `SlipItIn/Services/GameStateService.cs`
|
||||
- `SlipItIn/Services/SignalRService.cs`
|
||||
- `SlipItIn.Server/Hubs/GameHub.cs`
|
||||
- **Minimal Validation Command**:
|
||||
```bash
|
||||
dotnet build SlipItIn.slnx
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user