diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md
index 2c2d014..5510771 100644
--- a/docs/IMPLEMENTATION_PLAN.md
+++ b/docs/IMPLEMENTATION_PLAN.md
@@ -840,9 +840,12 @@ Policy: `@attribute [Authorize(Policy = "ClubViewer|ClubEditor|ClubAdmin")]`
## Übersicht
-Integration des Game-Management-Systems aus KoogleApp in Koogle.Web mit Clean Architecture.
+Implementierung des Game-Management-Systems in Koogle.Web mit Clean Architecture.
+Code wird komplett neu geschrieben, KoogleApp dient nur als Referenz.
Spiele laufen im Speicher auf der DayDetails-Seite ohne Browser-Reload.
+---
+
## Geklärte Anforderungen
| Aspekt | Entscheidung |
@@ -853,7 +856,14 @@ Spiele laufen im Speicher auf der DayDetails-Seite ohne Browser-Reload.
| Persistenz | **Nach jedem Wurf** in DB speichern (Game.GameData) |
| Multi-User | Spielfortschritt in anderen Sessions via **SignalR** sichtbar |
| Scheiss-Spiel Ende | Erster mit 0 Punkten **gewinnt**, andere bekommen Strafen nach Restpunkten |
-| Strafen-Berechnung | Fixbetrag pro Restpunkt (z.B. 0.10€ × 30 Punkte = 3.00€) |
+| Strafen-Berechnung | Trigger `ExpenseTriggerType.ExpensePoint` mit Multiplikator = Restpunkte |
+| SignalR | Komplett neu aufsetzen + **Auto-Reconnect** |
+| Port-Strategie | **Komplett neu schreiben**, KoogleApp nur als Referenz |
+| Concurrency | **RowVersion** auf Game Entity für Optimistic Concurrency |
+| Undo-Tiefe | **Unbegrenzt** (komplette Spiel-Historie) |
+| Testing | Nur Kern-Logik (GameLogicService) |
+
+---
## Design-Entscheidungen
@@ -862,10 +872,13 @@ Spiele laufen im Speicher auf der DayDetails-Seite ohne Browser-Reload.
| State-Speicherung | Separater `GameState` (nicht in DayState) |
| View-Wechsel | ViewMode-Enum + MudTabs (kein URL-Routing) |
| Spieltyp-System | IGameDefinition in Domain, Registry in Application |
-| Pin-Komponenten | Port aus KoogleApp mit neuem Namespace |
+| Pin-Komponenten | **Neu schreiben** (nicht portieren) |
| Persistenz | **Nach jedem Wurf** → DB (Game.GameData JSON) |
| Recovery | Bei Page-Load: aktives Spiel aus DB laden |
-| Multi-User Sync | **SignalR** für Echtzeit-Updates (Live-Tafel) |
+| Multi-User Sync | **SignalR** mit Auto-Reconnect für Echtzeit-Updates |
+| Strafen-Engine | **ITriggerService** für automatische Strafen |
+
+---
## Game Entity (erweitern)
@@ -885,11 +898,14 @@ public class Game : BaseEntity
public GameStatus Status { get; set; } // Active, Completed, Aborted
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
+ public byte[] RowVersion { get; set; } // Optimistic Concurrency
}
public enum GameStatus { Active, Completed, Aborted }
```
+---
+
## GameData JSON Struktur (Beispiel Training)
```json
@@ -910,40 +926,90 @@ public enum GameStatus { Active, Completed, Aborted }
"guid2": { "throwCount": 21, "pinCount": 138, "circleCount": 1 }
}
},
- "undoStack": [ ... ]
+ "undoStack": [ /* Unbegrenzte Historie */ ]
}
```
+---
+
## Architektur
```
-Domain: GameType Enum, IGameDefinition Interface
-Application: Games/ mit GameProgress, IGameLogicService, Training/, Shit/
-Web: Store/GameState/, Components/Game/
+Domain: GameType Enum, GameStatus Enum, IGameDefinition Interface
+Application: Games/ mit GameProgress, IGameLogicService, ITriggerService, Training/, Shit/
+Infrastructure: TriggerRepository, GameRepository
+Web: Store/GameState/, Components/Game/, Hubs/GameHub
```
+---
+
## Umsetzungsreihenfolge Phase 2
| ☐ | Phase | Bereich | Beschreibung | Dateien |
|---|-------|---------|--------------|---------|
-| ☐ | H1 | Foundation | GameState Fluxor | 5 |
-| ☐ | H2 | Components | Pin Input Components | 5 |
+| ☐ | **H0** | **Trigger-Engine** | **ITriggerService + Repository** | **5** |
+| ☐ | H1 | Foundation | GameState Fluxor + Enums | 6 |
+| ☐ | H2 | Components | Pin Input Components (neu schreiben) | 5 |
| ☐ | H3 | Framework | Game Definition Framework | 5 |
| ☐ | H4 | Games | Training Game | 5 |
-| ☐ | H5 | Games | Scheiss-Spiel | 5 |
+| ☐ | H5 | Games | Scheiss-Spiel + Trigger-Integration | 6 |
| ☐ | H6 | UI | Game Setup Dialog | 4 |
-| ☐ | H7 | Integration | DayDetails Integration | 3 |
-| ☐ | H8 | Features | Undo/Redo | 3 |
+| ☐ | H7 | Integration | DayDetails Tabs | 3 |
+| ☐ | H8 | Features | Undo (unbegrenzt, ohne Redo) | 2 |
| ☐ | H9 | Persistenz | DB Persistence & Recovery | 4 |
-| ☐ | H9b | Sync | SignalR Live-Updates | 4 |
-| ☐ | H10 | Testing | Game Tests | 3 |
+| ☐ | H9b | Sync | SignalR komplett + Auto-Reconnect | 5 |
+| ☐ | H10 | Testing | Kern-Logik Tests | 2 |
-**Geschätzte Dateien: ~48 neue/modifizierte**
+**Geschätzte Dateien: ~52 neue/modifizierte**
---
## Detaillierte Phasen
+### **Phase H0: Trigger-Engine (NEU)**
+
+Die Trigger-Engine wird für automatische Strafen benötigt (Scheiss-Spiel ist nur ein Beispiel, alle Spiele lösen Trigger für Strafen aus).
+
+**Dateien:**
+1. `src/Koogle.Domain/Interfaces/ITriggerRepository.cs`
+2. `src/Koogle.Infrastructure/Repositories/TriggerRepository.cs`
+3. `src/Koogle.Application/Interfaces/ITriggerService.cs`
+4. `src/Koogle.Application/Services/TriggerService.cs`
+5. `src/Koogle.Application/DTOs/TriggerDto.cs`
+
+**ITriggerService:**
+```csharp
+public interface ITriggerService
+{
+ ///
+ /// Gets all expenses linked to a trigger type for the current club.
+ ///
+ Task> GetExpensesForTriggerAsync(ExpenseTriggerType type);
+
+ ///
+ /// Fires a trigger, creating PersonExpenses for linked expenses.
+ ///
+ /// The trigger type (e.g., ExpensePoint)
+ /// Person receiving the expense
+ /// Day context
+ /// Optional game context
+ /// Multiplier for expense price (e.g., remaining points)
+ Task FireTriggerAsync(
+ ExpenseTriggerType type,
+ Guid personId,
+ Guid dayId,
+ Guid? gameId = null,
+ int multiplier = 1);
+}
+```
+
+**Logik FireTriggerAsync:**
+1. Hole alle Expenses die mit Trigger-Typ verknüpft sind (via ExpenseTrigger Join-Table)
+2. Für jede Expense: Erstelle PersonExpense mit `Price = Expense.Price × multiplier`
+3. multiplier = Restpunkte, z.B. im Scheiss-Spiel
+
+---
+
### **Phase H1: GameState Foundation**
**Dateien:**
@@ -952,6 +1018,7 @@ Web: Store/GameState/, Components/Game/
3. `src/Koogle.Web/Store/GameState/GameReducers.cs`
4. `src/Koogle.Web/Store/GameState/GameEffects.cs`
5. `src/Koogle.Domain/Enums/GameType.cs`
+6. `src/Koogle.Domain/Enums/PinStatus.cs`
**State-Struktur:**
```csharp
@@ -965,10 +1032,11 @@ public record GameState
public ThrowPanelState ThrowPanel { get; init; }
public ParticipantsState Participants { get; init; }
public object? GameModel { get; init; } // JSON-serializable game-specific data
- public UndoRedoState UndoRedo { get; init; }
+ public ImmutableList UndoStack { get; init; } // Unbegrenzt
public IReadOnlyList CompletedGames { get; init; }
public bool IsLoading { get; init; }
public string? Error { get; init; }
+ public bool IsConcurrencyConflict { get; init; } // RowVersion conflict
}
public record ThrowPanelState(
@@ -988,13 +1056,19 @@ public record ParticipantsState(
int CurrentPlayerIndex,
ParticipantsMode Mode
);
+
+public record GameSnapshot(
+ ThrowPanelState ThrowPanel,
+ ParticipantsState Participants,
+ object GameModel
+);
```
---
### **Phase H2: Pin Input Components**
-**Dateien (Port aus KoogleApp):**
+**Dateien (NEU SCHREIBEN):**
1. `src/Koogle.Web/Components/Game/Pin.razor`
2. `src/Koogle.Web/Components/Game/PinPanel.razor`
3. `src/Koogle.Web/Components/Game/NumberPanel.razor`
@@ -1041,11 +1115,27 @@ public interface IGameDefinition
}
```
-**GameProgress (Port):**
-- BeforeThrowState, AfterThrowState
+**GameProgress:**
+- BeforeThrowState, AfterThrowState Records
- PinCount(), IsCircle() Extension Methods
- NextRound(), EndOfGame() Helpers
+**GameModelFactory (für Polymorphie):**
+```csharp
+public static class GameModelFactory
+{
+ public static object Deserialize(string json, string gameType)
+ {
+ return gameType switch
+ {
+ "Training" => JsonSerializer.Deserialize(json),
+ "Shit" => JsonSerializer.Deserialize(json),
+ _ => throw new NotSupportedException($"Unknown game type: {gameType}")
+ };
+ }
+}
+```
+
---
### **Phase H4: Training Game**
@@ -1085,7 +1175,7 @@ public record PlayerStats
---
-### **Phase H5: Scheiss-Spiel**
+### **Phase H5: Scheiss-Spiel + Trigger-Integration**
**Dateien:**
1. `src/Koogle.Application/Games/Shit/ShitGameDefinition.cs`
@@ -1093,6 +1183,7 @@ public record PlayerStats
3. `src/Koogle.Application/Games/Shit/ShitGameLogicService.cs`
4. `src/Koogle.Web/Components/Game/Shit/ShitSetup.razor`
5. `src/Koogle.Web/Components/Game/Shit/ShitBoard.razor`
+6. Integration mit ITriggerService
**ShitGameModel:**
```csharp
@@ -1100,7 +1191,6 @@ public record ShitGameModel
{
public int ShitNumber { get; init; } // 1-9 (Scheiss-Zahl)
public int StartNumber { get; init; } // 10-1000 (Startzahl)
- public decimal PricePerPoint { get; init; } // 0.01-1.00 (€ pro Restpunkt)
public Dictionary PlayerPoints { get; init; }
public int CollectedPoints { get; set; }
public Guid? WinnerId { get; set; }
@@ -1109,15 +1199,15 @@ public record ShitGameModel
**Spiellogik:**
1. Spieler wirft
-2. Wenn Scheiss-Zahl oder Rinne → gesammelte Punkte werden vom Konto abgezogen, nächster Spieler
-3. Sonst → Punkte werden gesammelt (addiert)
+2. Wenn Scheiss-Zahl oder Rinne → bis dahin gesammelte Punkte werden zum Konto addiert, nächster Spieler
+3. Sonst → Punkte werden gesammelt und subtrahiert, denn der Spieler entscheidet die Punkte mitzunehmenan den nächsten Spieler zu übergeben (subtrahieren). Spiel-Spezifischer "Weiter-Button" wird benötigt.
4. Erster mit 0 Punkten → **GEWINNER**
-5. Spiel endet, alle anderen bekommen Strafe: `Restpunkte × PricePerPoint`
+5. Spiel endet, für ein Spieler 0 erreicht hat. Alle danderen sind Verlierer: `ITriggerService.FireTriggerAsync(ExpensePoint, personId, dayId, gameId, restpunkte)`
**ShitSetup.razor:**
- Scheiss-Zahl (1-9, Default: 5)
- Start-Zahl (10-1000, Default: 50)
-- **Betrag pro Punkt** (0.01€ - 1.00€, Default: 0.10€)
+- Warnung wenn kein ExpensePoint-Trigger konfiguriert
**ShitBoard.razor (Tafel):**
| Spieler | Punkte | Status |
@@ -1187,17 +1277,17 @@ public record ShitGameModel
---
-### **Phase H8: Undo/Redo**
+### **Phase H8: Undo (Unbegrenzt)**
**Dateien:**
-1. `src/Koogle.Web/Store/GameState/UndoRedoState.cs`
-2. `src/Koogle.Web/Store/GameState/UndoRedoActions.cs`
-3. `src/Koogle.Web/Components/Game/UndoRedoButtons.razor`
+1. `src/Koogle.Web/Store/GameState/GameState.cs` (UndoStack bereits definiert)
+2. `src/Koogle.Web/Components/Game/UndoButton.razor`
**Logic:**
-- Jeder Wurf erstellt Snapshot
-- Stack für Undo (max 20 Einträge)
+- Jeder Wurf erstellt GameSnapshot → auf UndoStack pushen
+- Stack ist **unbegrenzt** (komplette Spiel-Historie)
- "Letzten Wurf rückgängig" Button
+- Bei Undo: Pop vom Stack, State wiederherstellen
---
@@ -1218,8 +1308,9 @@ public record ShitGameModel
```
1. Spiel starten → Game Entity mit Status=Active erstellen
2. Nach jedem Wurf:
- - GameState → JSON serialisieren
+ - GameState → JSON serialisieren (inkl. UndoStack)
- Game.GameData updaten (Debounced, max 1x/500ms)
+ - RowVersion prüfen → bei Konflikt: IsConcurrencyConflict = true
3. Page Reload / Andere Session:
- LoadActiveGameAsync(dayId)
- Wenn Active Game existiert → GameState wiederherstellen
@@ -1228,15 +1319,39 @@ public record ShitGameModel
- Finales GameData speichern
```
+**Concurrency-Handling:**
+```csharp
+try
+{
+ await _context.SaveChangesAsync();
+}
+catch (DbUpdateConcurrencyException)
+{
+ // Reload aktuellen State, UI informieren
+ dispatcher.Dispatch(new ConcurrencyConflictAction(gameId));
+}
+```
+
---
-### **Phase H9b: SignalR Live-Updates**
+### **Phase H9b: SignalR Live-Updates (Komplett)**
**Dateien:**
1. `src/Koogle.Web/Hubs/GameHub.cs`
2. `src/Koogle.Web/Hubs/IGameHubClient.cs`
-3. `src/Koogle.Web/Store/GameState/GameEffects.cs` (erweitern für Broadcast)
-4. `src/Koogle.Web/Program.cs` (AddSignalR, MapHub)
+3. `src/Koogle.Web/Services/GameHubService.cs` (Wrapper)
+4. `src/Koogle.Web/Store/GameState/GameEffects.cs` (erweitern für Broadcast)
+5. `src/Koogle.Web/Program.cs` (AddSignalR, MapHub)
+
+**Program.cs Erweiterung:**
+```csharp
+// Services
+builder.Services.AddSignalR();
+builder.Services.AddScoped();
+
+// Endpoints
+app.MapHub("/gamehub");
+```
**SignalR Hub:**
```csharp
@@ -1253,68 +1368,74 @@ public interface IGameHubClient
{
Task GameStateUpdated(GameStateDto state);
Task GameEnded(GameResultDto result);
+ Task ConcurrencyConflict(Guid gameId);
}
```
+**Auto-Reconnect (Client-Side):**
+```csharp
+_hubConnection = new HubConnectionBuilder()
+ .WithUrl(Navigation.ToAbsoluteUri("/gamehub"))
+ .WithAutomaticReconnect(new[] {
+ TimeSpan.Zero,
+ TimeSpan.FromSeconds(2),
+ TimeSpan.FromSeconds(10),
+ TimeSpan.FromSeconds(30)
+ })
+ .Build();
+
+_hubConnection.Reconnected += async (connectionId) =>
+{
+ // Re-join active game
+ if (GameState.Value.ActiveGameId.HasValue)
+ {
+ await _hubConnection.InvokeAsync("JoinGame", GameState.Value.ActiveGameId.Value);
+ // Reload current state from DB
+ Dispatcher.Dispatch(new LoadActiveGameAction(GameState.Value.DayId!.Value));
+ }
+};
+```
+
**Flow:**
1. Tafel-View öffnen → `JoinGame(gameId)`
2. Nach jedem Wurf: DB Save → `Clients.Group(gameId).GameStateUpdated(state)`
3. Andere Sessions empfangen Update → Reducer aktualisiert State
-4. Tafel-View schließen → `LeaveGame(gameId)`
+4. Bei Reconnect → State aus DB laden
+5. Tafel-View schließen → `LeaveGame(gameId)`
---
-### **Phase H10: Testing**
+### **Phase H10: Testing (Kern-Logik)**
**Dateien:**
1. `test/Koogle.Tests/Application/Games/TrainingGameLogicServiceTests.cs`
2. `test/Koogle.Tests/Application/Games/ShitGameLogicServiceTests.cs`
-3. `test/Koogle.Tests/Web/GameStateReducersTests.cs`
**Test-Cases:**
-- Pin-Count Berechnung
-- Kranz-Erkennung (8 äußere + Mitte steht)
-- Scheiss-Spiel: Gewinner bei 0
-- Scheiss-Spiel: Strafen-Berechnung
-- Reducer: State-Transformationen
-
----
-
-## Scheiss-Spiel Spielende-Logik
-
-```
-1. Spieler erreicht 0 Punkte → GEWINNER (WinnerId setzen)
-2. Spiel endet automatisch (IsGameActive = false)
-3. Für jeden Verlierer:
- - Strafe = Restpunkte × Betrag_pro_Punkt
- - PersonExpense erstellen (ExpenseType: GamePenalty)
-4. Game.GameData speichern (JSON mit Ergebnis)
-5. CompletedGames-Liste aktualisieren
-```
-
----
-
-## Kritische Dateien (KoogleApp → Koogle.Web Port)
-
-| Quelle (KoogleApp) | Ziel (Koogle.Web) |
-|--------------------|-------------------|
-| `src/KoogleApp/Games/GameProgress.cs` | `src/Koogle.Application/Games/GameProgress.cs` |
-| `src/KoogleApp/Store/Game/ThrowPanel/State.cs` | In `GameState.cs` integriert |
-| `src/KoogleApp/Games/Training/GameTrainingService.cs` | `src/Koogle.Application/Games/Training/TrainingGameLogicService.cs` |
-| `src/KoogleApp/Components/Controls/PinPanel.razor` | `src/Koogle.Web/Components/Game/PinPanel.razor` |
-| `src/KoogleApp/Components/Controls/NumberPanel.razor` | `src/Koogle.Web/Components/Game/NumberPanel.razor` |
+- Pin-Count Berechnung (0-9 Kegel)
+- Kranz-Erkennung (8 äußere + Mitte steht = 8 Punkte)
+- Scheiss-Spiel: Gewinner bei 0 Punkten
+- Scheiss-Spiel: Korrekte Punkte-Abzüge
+- Training: Statistik-Updates nach Wurf
---
## Migration
```bash
-# Game Entity erweitern: GameType, Status, StartedAt, CompletedAt
+# Game Entity erweitern: GameType, Status, StartedAt, CompletedAt, RowVersion
dotnet ef migrations add ExtendGameEntity --project src/Koogle.Infrastructure --startup-project src/Koogle.Web --context AppDbContext --output-dir Data/Migrations
dotnet ef database update -p src/Koogle.Infrastructure -s src/Koogle.Web --context AppDbContext
```
+**GameConfiguration.cs erweitern:**
+```csharp
+builder.Property(g => g.RowVersion)
+ .IsRowVersion()
+ .IsConcurrencyToken();
+```
+
---
## Berechtigungen Phase 2
@@ -1325,15 +1446,32 @@ dotnet ef database update -p src/Koogle.Infrastructure -s src/Koogle.Web --conte
---
+## Risiken & Mitigationen
+
+| Risiko | Mitigation |
+|--------|------------|
+| Polymorphe GameModel-Serialisierung | GameModelFactory mit switch über GameType |
+| SignalR + Blazor Server | Separater Hub `/gamehub`, keine Interferenz |
+| Concurrent Updates | RowVersion + UI-Konfliktwarnung |
+| Fehlende Trigger-Stammdaten | Warnung in ShitSetup wenn kein ExpensePoint-Trigger |
+
+---
+
## Zusammenfassung Phase 2
-**11 Phasen (H1-H10 + H9b)** → **~48 Dateien** → **Detaillierte Spielverwaltung**
+**12 Phasen (H0-H10 inkl. H9b)** → **~52 Dateien** → **Detaillierte Spielverwaltung**
**Kernfeatures:**
- Spiele mit Wurf-für-Wurf Eingabe
-- DB-Persistenz nach jedem Wurf
-- SignalR Live-Updates für Multi-User
+- Trigger-Engine für automatische Strafen
+- DB-Persistenz nach jedem Wurf mit RowVersion
+- SignalR Live-Updates + Auto-Reconnect für Multi-User
- Recovery bei Page-Reload
-- Undo/Redo für Wurf-Korrekturen
+- Unbegrenzte Undo-Historie
-Bereit für Implementierung Phase H1
+**Abhängigkeiten zu bestehenden Entities:**
+- ExpenseTrigger + Trigger (bereits vorhanden)
+- ExpenseTriggerType.ExpensePoint (bereits vorhanden)
+- PersonExpense mit GameId (bereits vorhanden)
+
+Bereit für Implementierung Phase H0