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