Compare commits

...

23 Commits

Author SHA1 Message Date
tim
118660a207 Merge pull request 'Tech-Debt-Cleanup: Doku, Security-Hardening, Server-Validierung, Client-Stabilitaet' (#2) from feature/techdebt-cleanup into master
Some checks failed
Build / build (push) Successful in 2m11s
OpenWiki Update / update (push) Has been cancelled
Reviewed-on: #2
2026-09-01 19:00:25 +00:00
Tim Krampitz
7953b8ddd0 P1: Challenger-Zugehörigkeit zum Spiel validieren; P2: MAUI-Workload im CI installieren
All checks were successful
Build / build (pull_request) Successful in 6m10s
- GameService.CreateChallengeAsync prüft jetzt, dass challengingPlayerId zum gameId gehört
- Zwei neue Tests für Cross-Game-/Fremdspieler-Challenges
- CI: dotnet workload install maui vor dem Restore, damit MAUI auf frischen Runnern baut
2026-09-01 20:50:46 +02:00
Tim Krampitz
660fc4d311 Fix CI build workflow, index casing, and test assertions
All checks were successful
Build / build (pull_request) Successful in 2m16s
2026-08-23 14:46:09 +02:00
Tim Krampitz
5e22f0ab9b Merge branch 'feature/techdebt-cleanup' of https://git.krampitz.win/tim/SlipItIn into feature/techdebt-cleanup
Some checks failed
Build / build (pull_request) Failing after 3m29s
Signed-off-by: Tim Krampitz <Tim.Krampitz@live.com>
2026-08-22 20:14:46 +02:00
Tim Krampitz
af6528ee68 Tech-Debt-Cleanup: Doku, Security-Hardening, Server-Validierung, Client-Stabilitaet
Some checks failed
Build / build (pull_request) Has been cancelled
Doku/Repo:
- README.md mit Setup, Run-Anleitung, Konfiguration und Sprachpolicy neu geschrieben
- appsettings.json: committed JWT-Key und irreführende LocalDB-Connection-String entfernt
- appsettings.Development.json: expliziter Dev-Only JWT-Key hinterlegt
- build.yml: Build-CI für master/PRs hinzugefügt
- slnx/aspire.config.json: Pfad-Casing korrigiert (Linux-kompatibel)
- .gitignore: Kommentar zu den Legacy-SQLite-Einträgen

Server:
- Jwt:Key ist in Production Pflicht (Fail-fast statt Fallback-Secret)
- Passwort-Mindestlänge 8 Zeichen bei Registrierung
- StartGame: nur aus Lobby, mindestens 2 Spieler
- SetPlayerReady: gameId/Status geprüft
- SubmitSlip: Game-/Round-Status, Round-Membership, IsUsed validiert
- CreateChallenge: Round-Bindung, kein Self-Challenge, nur gespielte Karten, keine Doppel-Challenges
- ResolveChallenge: Pending-Guard, Scoring (Score/SuccessfulSlips/FailedSlips), Approved-Transfer gefixt
- GetPlayerHand: nur Karten der aktiven Runde
- RoundTimeRemaining: StartedAt wird bei Round-Aktivierung gesetzt
- Lobby-Code: crypto-random mit Kollisions-Retry
- EF: explizite NpgsqlDataSource-Auflösung aus DI
- Hub: Reconnect-Gruppenbeitritt, ConnectionId-Cleanup beim Disconnect, ChallengingPlayerName gesetzt, SlipChallenged/ChallengeResolved konsistent

Client:
- Offline-Start löscht Session nicht mehr (NetworkError vs. Invalid unterschieden)
- ResolveChallenge geht durch die Offline-Queue (kein Crash mehr bei Disconnect)
- Queue: Poison-Entries werden nach 3 Retries verworfen statt die Queue ewig zu blockieren
- SignalR: Verbindung wird sauber disposed/rebuildet, Token immer zur Laufzeit gelesen, Connect-Lock gegen parallele Starts
- ViewModels: Thread-Marshaling in allen Receive-Handlern, kein async void mehr
- LobbyViewModel: Logout deaktiviert den Singleton (kein stale-Navigation mehr), Reaktivierung via Activate()
- GameBoard: lokaler 1s-Timer für RoundTimeRemaining
- Overlay: x:DataType für kompilierte Bindings, Frame→Border
- csproj: Preview-Logging-Package auf 10.0.10, Template-Cruft entfernt
2026-08-22 19:37:54 +02:00
Tim Krampitz
7d4344c062 Chore
All checks were successful
OpenWiki Update / update (push) Successful in 13m54s
2026-08-22 19:37:01 +02:00
Tim Krampitz
510cc0e520 Testprojekt hinzugefügt & umfassende Server-Tests
Neues Testprojekt SlipItIn.Server.Tests erstellt und in die Solution eingebunden. Unit- und Integrationstests für AuthController, DbSeeder, GameHub und GameService implementiert. TestDbHelper für InMemory-DbContexts und Testdaten hinzugefügt. Tests prüfen Erfolgs- und Fehlerfälle inkl. Validierungen, Fehlerbehandlung und DB-Zustände.
2026-08-22 19:31:45 +02:00
Tim Krampitz
b1435b2c1e Merge branch 'master' of https://git.krampitz.win/tim/SlipItIn 2026-08-22 19:10:32 +02:00
Tim Krampitz
fe1319a34c Removed obsolete documentation and planning. 2026-08-22 19:10:29 +02:00
Tim Krampitz
dede059983 Removed obsolete documentation and planning. 2026-08-22 19:08:33 +02:00
Tim Krampitz
8078ac2212 Merge branch 'master' of https://git.krampitz.win/tim/SlipItIn 2026-08-22 19:07:15 +02:00
Tim Krampitz
540cda102f Support Android emulator API access
Enable CORS in all environments and disable HTTPS redirect in dev.
Seed the database after migrations.
Make the API base URL configurable, defaulting to the Android
emulator host, and use per-config Android manifests.
2026-08-22 19:05:53 +02:00
tim
bf306a6c15 Merge pull request 'openwiki-update.yml: Actions & Schutzmechanismus verbessert' (#1) from openwiki/update into master
Some checks failed
OpenWiki Update / update (push) Has been cancelled
Reviewed-on: #1
2026-08-09 15:24:31 +00:00
Tim Krampitz
3296ecdfb3 JWT- und Session-Validierung verbessert, UI aktualisiert
- JWT-Authentifizierung prüft nun, ob User aktiv und existent ist
- Session-Validierung beim App-Start via ValidateSessionAsync
- Ungültige Sessions werden entfernt, SignalR-Verbindung getrennt
- ApiService und IApiService um ValidateSessionAsync erweitert
- LoginViewModel nutzt Session-Check und behandelt SignalR-Fehler
- UI für eigene Phrasen auf <Border> mit Stil-Anpassung umgestellt
2026-08-08 21:30:18 +02:00
Tim Krampitz
6f3c0eb716 Synchronisierungs- und Verbindungsstatus verbessert
Neuer Nachrichtentyp SyncStateChangedMessage zur Übermittlung des Sync-Status über das MVVM-Messaging-System eingeführt. GameStateService sendet nach relevanten Aktionen den aktuellen Sync-Status. Methoden zur Synchronisierung sind jetzt thread-sicher und melden Statusänderungen laufend. Fehler beim Senden von Queue-Aktionen werden einzeln behandelt und führen zu Fehlermeldungen im Messenger. ViewModels aktualisieren neue Properties (IsSyncing, SyncStatusText, PendingSyncActions) bei Statusänderungen. XAML-Seiten zeigen Verbindungs- und Sync-Status sowie Offline-Aktionen an. SignalRService meldet Verbindungsstatus konsistenter, auch bei Fehlern und Disconnects.
2026-08-08 21:23:27 +02:00
Tim Krampitz
0f9cc1703a openwiki-update.yml: Actions & Schutzmechanismus verbessert
- Actions checkout und setup-node auf neuere Versionen aktualisiert
- fetch-depth: 0 für vollständige Git-Historie gesetzt
- npm-Installationsbefehl für openwiki vereinfacht (immer neueste Version)
- Schritt „Restore protected workflow file“ hinzugefügt, um Überschreiben durch OpenWiki zu verhindern
- Kleine Anpassung im PR-Body (Leerzeile entfernt)
2026-08-08 21:19:43 +02:00
Tim Krampitz
936c8bde28 openwiki-update.yml: Actions & Schutzmechanismus verbessert
All checks were successful
OpenWiki Update / update (push) Successful in 2m21s
- Actions checkout und setup-node auf neuere Versionen aktualisiert
- fetch-depth: 0 für vollständige Git-Historie gesetzt
- npm-Installationsbefehl für openwiki vereinfacht (immer neueste Version)
- Schritt „Restore protected workflow file“ hinzugefügt, um Überschreiben durch OpenWiki zu verhindern
- Kleine Anpassung im PR-Body (Leerzeile entfernt)
2026-08-08 21:15:23 +02:00
Tim Krampitz
2ce9bcd4c4 Update openwiki-update.yml 2026-08-08 21:07:50 +02:00
tim
c67c549f48 docs: update OpenWiki 2026-08-08 18:09:52 +00:00
tim
37616a9316 .github/workflows/openwiki-update.yml aktualisiert 2026-08-08 18:06:26 +00:00
Tim Krampitz
18a9d1ab74 Robustere Initialisierung & Routing-Refaktor
- InitializeAsync in InitializeSafeAsync mit Fehlerbehandlung umbenannt, App-Start robuster gestaltet
- Asynchrone Initialisierung bei Window.Created
- Navigation vereinheitlicht: Routing.RegisterRoute statt Doppelslash, Navigation ohne Präfix
- Flyout-Menü in AppShell deaktiviert, ShellContent-Einträge entfernt
- Login/Register-ViewModels nutzen private Felder statt ObservableProperty
- SignalRService nutzt AccessToken aus AuthSessionService
- GameStateService: Automatischer Resync bei Verbindung, Fehlerhandling verbessert
- Navigation in ViewModels an neues Routing angepasst
- Aufräumarbeiten: Entfernte auskommentierte Messenger-Registrierungen, Initialisierung vereinheitlicht
2026-08-08 13:12:57 +02:00
Tim Krampitz
a5db6dc26e Merge branch 'master' of https://git.krampitz.win/tim/SlipItIn 2026-08-08 12:50:27 +02:00
Tim Krampitz
118a62a804 Update der Dokumentation 2026-08-08 12:50:15 +02:00
63 changed files with 2997 additions and 1550 deletions

40
.github/workflows/build.yml vendored Normal file
View 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

View File

@@ -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,6 +58,10 @@ 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.

2
.gitignore vendored
View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -1 +1,53 @@
# 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.

View File

@@ -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)

View File

@@ -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>

View 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);
}
}

View 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());
}
}

View 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);
}
}

View 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));
}
}

View 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>

View 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));
}
}

View File

@@ -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;

View 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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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);
}
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");
}
}

View File

@@ -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>

View File

@@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Jwt": {
"Key": "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef"
}
}

View File

@@ -7,11 +7,7 @@
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"postgresdb": "Server=(localdb)\\postgres;Database=postgresdb"
},
"Jwt": {
"Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
"Issuer": "SlipItInServer",
"Audience": "SlipItInClient",
"ExpirationMinutes": 1440

View File

@@ -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>

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.11" />
</ItemGroup>
</Project>

View File

@@ -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>

View File

@@ -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 (_, _) =>
{
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()
{
if (!await _startupLock.WaitAsync(0))
return;
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))
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();
}
}
}

View File

@@ -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
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>

View File

@@ -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));
}
}

View 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)
{
}
}

View File

@@ -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; }
}

View 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>

View 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>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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()
{
await _syncLock.WaitAsync();
try
{
PublishSyncState(true, "Syncing...", _queuedActions.Count);
if (CurrentGameState?.GameId > 0 && _signalR.IsConnected)
await _signalR.RequestGameStateAsync(CurrentGameState.GameId);
await FlushQueuedActionsAsync();
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)));
}
}

View File

@@ -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();
}

View File

@@ -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,11 +37,37 @@ public class SignalRService : ISignalRService
if (IsConnected)
return true;
await _connectLock.WaitAsync(cancellationToken);
try
{
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);
@@ -46,19 +76,38 @@ public class SignalRService : ISignalRService
}
catch (Exception ex)
{
ConnectionStateChanged?.Invoke(this, false);
ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}");
return false;
}
}
finally
{
_connectLock.Release();
}
}
public async Task DisconnectAsync()
{
await _connectLock.WaitAsync();
try
{
if (_hubConnection is null)
return;
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();
}
}

View File

@@ -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>

View File

@@ -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
@@ -155,16 +218,37 @@ public partial class GameBoardViewModel : BaseViewModel,
}
public void Receive(ChallengeReceivedMessage message)
{
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;

View File

@@ -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()

View File

@@ -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));
}
}

View File

@@ -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...");
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -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}" />

View File

@@ -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();
}
}

View File

@@ -1,5 +1,5 @@
{
"appHost": {
"path": "SlipItIN.AppHost/SlipItIN.AppHost.csproj"
"path": "SlipItIn.AppHost/SlipItIn.AppHost.csproj"
}
}

View File

@@ -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"
}

View File

@@ -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.

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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
```

View File

@@ -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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
[Client App] LoginVM.LoginAsync() ──> REST Auth ──> Save Token in SecureStorage ──> SignalR.ConnectAsync()
[Player] JoinLobby() ──> Verify MaxPlayers & Status ───────────┤
[Host] LobbyVM.CreateLobbyAsync() ──> SignalR.CreateLobbyAsync() ──> Server Generates Code ──┤
[Player] PlayerReady() ──> Set IsReady = true ───────────────────> Broadcast GameStateUpdated
[Player] LobbyVM.JoinLobbyAsync() ──> SignalR.JoinLobbyAsync(code) ──────────────────────────┤
[Host] StartGame() ──> Deal 5 Random Cards Per Player ─────────┴──> Unicast PlayerHandUpdated
[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
```