update planning

This commit is contained in:
beo3000 2025-12-26 12:28:00 +01:00
parent fab82fcde8
commit 7a96cf0cc4
1 changed files with 214 additions and 76 deletions

View File

@ -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
{
/// <summary>
/// Gets all expenses linked to a trigger type for the current club.
/// </summary>
Task<IEnumerable<ExpenseDto>> GetExpensesForTriggerAsync(ExpenseTriggerType type);
/// <summary>
/// Fires a trigger, creating PersonExpenses for linked expenses.
/// </summary>
/// <param name="type">The trigger type (e.g., ExpensePoint)</param>
/// <param name="personId">Person receiving the expense</param>
/// <param name="dayId">Day context</param>
/// <param name="gameId">Optional game context</param>
/// <param name="multiplier">Multiplier for expense price (e.g., remaining points)</param>
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<GameSnapshot> UndoStack { get; init; } // Unbegrenzt
public IReadOnlyList<GameSummaryDto> 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<TrainingGameModel>(json),
"Shit" => JsonSerializer.Deserialize<ShitGameModel>(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<Guid, int> 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<GameHubService>();
// Endpoints
app.MapHub<GameHub>("/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