diff --git a/src/Koogle.Application/DependencyInjection.cs b/src/Koogle.Application/DependencyInjection.cs index 22a2510..9994457 100644 --- a/src/Koogle.Application/DependencyInjection.cs +++ b/src/Koogle.Application/DependencyInjection.cs @@ -35,6 +35,7 @@ namespace Koogle.Application services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); // Note: Game types are registered in Koogle.Web where Blazor components are defined diff --git a/src/Koogle.Application/Interfaces/IGameEventService.cs b/src/Koogle.Application/Interfaces/IGameEventService.cs new file mode 100644 index 0000000..1739b58 --- /dev/null +++ b/src/Koogle.Application/Interfaces/IGameEventService.cs @@ -0,0 +1,108 @@ +using Koogle.Application.DTOs; + +namespace Koogle.Application.Interfaces; + +/// +/// Service for handling game events that may trigger automatic expense assignments. +/// Supports inverse expenses (charged to all other participants). +/// +public interface IGameEventService +{ + /// + /// Registers a gutter throw (left or right) and fires associated triggers. + /// + /// The player who threw the gutter. + /// The current day. + /// The current game. + /// All participant IDs (required for inverse expenses). + /// True if this is the first throw (Anwurf). + /// Cancellation token. + /// List of assigned expenses. + Task> RegisterGutterAsync( + Guid personId, + Guid dayId, + Guid gameId, + IReadOnlyList allParticipantIds, + bool isFirstThrow = false, + CancellationToken ct = default); + + /// + /// Registers a strike (alle Neune) and fires associated triggers. + /// + Task> RegisterStrikeAsync( + Guid personId, + Guid dayId, + Guid gameId, + IReadOnlyList allParticipantIds, + CancellationToken ct = default); + + /// + /// Registers a bell hit (Klingel) and fires associated triggers. + /// + Task> RegisterBellAsync( + Guid personId, + Guid dayId, + Guid gameId, + IReadOnlyList allParticipantIds, + CancellationToken ct = default); + + /// + /// Registers a circle (Kranz) and fires associated triggers. + /// + Task> RegisterCircleAsync( + Guid personId, + Guid dayId, + Guid gameId, + IReadOnlyList allParticipantIds, + CancellationToken ct = default); + + /// + /// Registers a no-wood throw (kein Holz) and fires associated triggers. + /// + Task> RegisterNoWoodAsync( + Guid personId, + Guid dayId, + Guid gameId, + IReadOnlyList allParticipantIds, + CancellationToken ct = default); + + /// + /// Registers player elimination and fires associated triggers. + /// + Task> RegisterEliminatedAsync( + Guid personId, + Guid dayId, + Guid gameId, + IReadOnlyList allParticipantIds, + CancellationToken ct = default); + + /// + /// Registers expense points (e.g., Scheißspiel points) with multiplier. + /// + /// The player receiving points. + /// The current day. + /// The current game. + /// All participant IDs (required for inverse expenses). + /// Number of expense points (multiplier). + /// Cancellation token. + Task> RegisterExpensePointsAsync( + Guid personId, + Guid dayId, + Guid gameId, + IReadOnlyList allParticipantIds, + int points, + CancellationToken ct = default); + + /// + /// Registers player absence on match day. + /// + /// The absent player. + /// The current day. + /// All participant IDs (required for inverse expenses). + /// Cancellation token. + Task> RegisterAbsentAsync( + Guid personId, + Guid dayId, + IReadOnlyList allParticipantIds, + CancellationToken ct = default); +} diff --git a/src/Koogle.Application/Interfaces/ITriggerService.cs b/src/Koogle.Application/Interfaces/ITriggerService.cs index 2e5869f..cd8f3f0 100644 --- a/src/Koogle.Application/Interfaces/ITriggerService.cs +++ b/src/Koogle.Application/Interfaces/ITriggerService.cs @@ -33,19 +33,22 @@ public interface ITriggerService /// /// Fires a trigger, creating PersonExpenses for all linked expenses. + /// For inverse expenses, charges all participants except the triggering person. /// /// The trigger type (e.g., ExpensePoint, Circle, Strike). - /// The person receiving the expense(s). + /// The person who triggered the event. /// The day context for the expense. /// Optional game context for the expense. + /// All participant IDs (required for inverse expenses). /// Multiplier for expense price (e.g., remaining points in Scheiss-Spiel). Default is 1. /// Cancellation token. /// A list of created PersonExpense DTOs. Task> FireTriggerAsync( ExpenseTriggerType type, - Guid personId, + Guid triggeringPersonId, Guid dayId, Guid? gameId = null, + IReadOnlyList? allParticipantIds = null, int multiplier = 1, CancellationToken ct = default); diff --git a/src/Koogle.Application/Services/GameEventService.cs b/src/Koogle.Application/Services/GameEventService.cs new file mode 100644 index 0000000..7596652 --- /dev/null +++ b/src/Koogle.Application/Services/GameEventService.cs @@ -0,0 +1,184 @@ +using Koogle.Application.DTOs; +using Koogle.Application.Interfaces; +using Koogle.Domain.Enums; + +namespace Koogle.Application.Services; + +/// +/// Handles game events and fires associated expense triggers. +/// Supports inverse expenses (charged to all other participants). +/// Example: A strike with inverse expense charges all OTHER players. +/// +public class GameEventService : IGameEventService +{ + private readonly ITriggerService _triggerService; + + public GameEventService(ITriggerService triggerService) + { + _triggerService = triggerService; + } + + /// + public async Task> RegisterGutterAsync( + Guid personId, + Guid dayId, + Guid gameId, + IReadOnlyList allParticipantIds, + bool isFirstThrow = false, + CancellationToken ct = default) + { + var expenses = new List(); + + // Fire standard Gutter trigger (left or right gutter) + var gutterExpenses = await _triggerService.FireTriggerAsync( + ExpenseTriggerType.Gutter, + personId, + dayId, + gameId, + allParticipantIds, + ct: ct); + expenses.AddRange(gutterExpenses); + + // If gutter on first throw, also fire FullGutter trigger + if (isFirstThrow) + { + var fullGutterExpenses = await _triggerService.FireTriggerAsync( + ExpenseTriggerType.FullGutter, + personId, + dayId, + gameId, + allParticipantIds, + ct: ct); + expenses.AddRange(fullGutterExpenses); + } + + return expenses; + } + + /// + public async Task> RegisterStrikeAsync( + Guid personId, + Guid dayId, + Guid gameId, + IReadOnlyList allParticipantIds, + CancellationToken ct = default) + { + // Strike = "alle Neune" - if inverse, charges all other players + return await _triggerService.FireTriggerAsync( + ExpenseTriggerType.Strike, + personId, + dayId, + gameId, + allParticipantIds, + ct: ct); + } + + /// + public async Task> RegisterBellAsync( + Guid personId, + Guid dayId, + Guid gameId, + IReadOnlyList allParticipantIds, + CancellationToken ct = default) + { + // Bell hit = "Klingel getroffen" + return await _triggerService.FireTriggerAsync( + ExpenseTriggerType.Bell, + personId, + dayId, + gameId, + allParticipantIds, + ct: ct); + } + + /// + public async Task> RegisterCircleAsync( + Guid personId, + Guid dayId, + Guid gameId, + IReadOnlyList allParticipantIds, + CancellationToken ct = default) + { + // Circle = "Kranz" - specific pin arrangement + return await _triggerService.FireTriggerAsync( + ExpenseTriggerType.Circle, + personId, + dayId, + gameId, + allParticipantIds, + ct: ct); + } + + /// + public async Task> RegisterNoWoodAsync( + Guid personId, + Guid dayId, + Guid gameId, + IReadOnlyList allParticipantIds, + CancellationToken ct = default) + { + // No wood = "Kein Holz" - no pins knocked down + return await _triggerService.FireTriggerAsync( + ExpenseTriggerType.NoWood, + personId, + dayId, + gameId, + allParticipantIds, + ct: ct); + } + + /// + public async Task> RegisterEliminatedAsync( + Guid personId, + Guid dayId, + Guid gameId, + IReadOnlyList allParticipantIds, + CancellationToken ct = default) + { + // Player eliminated from game + return await _triggerService.FireTriggerAsync( + ExpenseTriggerType.Eliminated, + personId, + dayId, + gameId, + allParticipantIds, + ct: ct); + } + + /// + public async Task> RegisterExpensePointsAsync( + Guid personId, + Guid dayId, + Guid gameId, + IReadOnlyList allParticipantIds, + int points, + CancellationToken ct = default) + { + // Expense points with multiplier (e.g., remaining points in Scheißspiel) + return await _triggerService.FireTriggerAsync( + ExpenseTriggerType.ExpensePoint, + personId, + dayId, + gameId, + allParticipantIds, + multiplier: points, + ct: ct); + } + + /// + public async Task> RegisterAbsentAsync( + Guid personId, + Guid dayId, + IReadOnlyList allParticipantIds, + CancellationToken ct = default) + { + // Player absent on match day - no gameId needed + return await _triggerService.FireTriggerAsync( + ExpenseTriggerType.Absent, + personId, + dayId, + gameId: null, + allParticipantIds, + ct: ct); + } +} diff --git a/src/Koogle.Application/Services/TriggerService.cs b/src/Koogle.Application/Services/TriggerService.cs index c6abc80..b393427 100644 --- a/src/Koogle.Application/Services/TriggerService.cs +++ b/src/Koogle.Application/Services/TriggerService.cs @@ -82,9 +82,10 @@ public class TriggerService : ITriggerService /// public async Task> FireTriggerAsync( ExpenseTriggerType type, - Guid personId, + Guid triggeringPersonId, Guid dayId, Guid? gameId = null, + IReadOnlyList? allParticipantIds = null, int multiplier = 1, CancellationToken ct = default) { @@ -105,11 +106,11 @@ public class TriggerService : ITriggerService if (day.Status == DayStatus.Closed) throw new InvalidOperationException("Cannot add expenses to a closed day."); - // Validate person exists - var person = await context.Persons - .Where(p => p.Id == personId && p.ClubId == _clubContext.ClubId && !p.IsDeleted) + // Validate triggering person exists + var triggeringPerson = await context.Persons + .Where(p => p.Id == triggeringPersonId && p.ClubId == _clubContext.ClubId && !p.IsDeleted) .FirstOrDefaultAsync(ct) - ?? throw new InvalidOperationException($"Person with id {personId} not found."); + ?? throw new InvalidOperationException($"Person with id {triggeringPersonId} not found."); var createdExpenses = new List(); @@ -118,27 +119,51 @@ public class TriggerService : ITriggerService // Calculate price with multiplier var price = expense.Price * multiplier; - var personExpense = new PersonExpense + // Determine target persons based on IsInverse flag + IEnumerable targetPersonIds; + if (expense.IsInverse) { - Id = Guid.NewGuid(), - PersonId = personId, - ExpenseId = expense.Id, - DayId = dayId, - GameId = gameId, - ClubId = _clubContext.ClubId, - Name = expense.Name, - Price = price, - ExpenseType = expense.ExpenseType, - PersonExpenseStatus = PersonExpenseStatus.Open, - AssignedById = Guid.Empty, // TODO: Get from ICurrentUserService when available - CreatedAt = DateTime.UtcNow, - IsDeleted = false - }; + // Inverse: charge all participants EXCEPT the triggering person + if (allParticipantIds == null || allParticipantIds.Count == 0) + { + // No participants provided - skip inverse expense + continue; + } + targetPersonIds = allParticipantIds.Where(id => id != triggeringPersonId); + } + else + { + // Normal: charge only the triggering person + targetPersonIds = [triggeringPersonId]; + } - context.PersonExpenses.Add(personExpense); - createdExpenses.Add(personExpense); + foreach (var targetPersonId in targetPersonIds) + { + var personExpense = new PersonExpense + { + Id = Guid.NewGuid(), + PersonId = targetPersonId, + ExpenseId = expense.Id, + DayId = dayId, + GameId = gameId, + ClubId = _clubContext.ClubId, + Name = expense.Name, + Price = price, + ExpenseType = expense.ExpenseType, + PersonExpenseStatus = PersonExpenseStatus.Open, + AssignedById = Guid.Empty, // TODO: Get from ICurrentUserService when available + CreatedAt = DateTime.UtcNow, + IsDeleted = false + }; + + context.PersonExpenses.Add(personExpense); + createdExpenses.Add(personExpense); + } } + if (createdExpenses.Count == 0) + return []; + await context.SaveChangesAsync(ct); // Reload with navigation properties diff --git a/src/Koogle.Web/Store/DayState/DayActions.cs b/src/Koogle.Web/Store/DayState/DayActions.cs index 081ec82..e42b00b 100644 --- a/src/Koogle.Web/Store/DayState/DayActions.cs +++ b/src/Koogle.Web/Store/DayState/DayActions.cs @@ -261,3 +261,8 @@ public record UpdatePersonExpenseStatusSuccessAction(PersonExpenseDto Expense); /// Action dispatched when updating person expense status fails. /// public record UpdatePersonExpenseStatusFailureAction(string Error); + +/// +/// Action dispatched when trigger-based expenses are created (e.g., Gutter, Strike). +/// +public record TriggerExpensesCreatedAction(IReadOnlyList Expenses); diff --git a/src/Koogle.Web/Store/DayState/DayReducers.cs b/src/Koogle.Web/Store/DayState/DayReducers.cs index 8922dec..e22cf7a 100644 --- a/src/Koogle.Web/Store/DayState/DayReducers.cs +++ b/src/Koogle.Web/Store/DayState/DayReducers.cs @@ -555,6 +555,16 @@ public static class DayReducers Error = action.Error }; + /// + /// Handles TriggerExpensesCreatedAction - adds trigger-created expenses to list. + /// + [ReducerMethod] + public static DayState OnTriggerExpensesCreated(DayState state, TriggerExpensesCreatedAction action) + => state with + { + SelectedDayExpenses = [.. state.SelectedDayExpenses, .. action.Expenses] + }; + /// /// Handles DeletePersonExpenseAction - sets loading state. /// diff --git a/src/Koogle.Web/Store/GameState/GameEffects.cs b/src/Koogle.Web/Store/GameState/GameEffects.cs index 6c9dbd8..1349f36 100644 --- a/src/Koogle.Web/Store/GameState/GameEffects.cs +++ b/src/Koogle.Web/Store/GameState/GameEffects.cs @@ -7,6 +7,7 @@ using Koogle.Domain.Enums; using Koogle.Infrastructure.Configuration; using Koogle.Web.Hubs; using Koogle.Web.Services; +using Koogle.Web.Store.DayState; using Microsoft.Extensions.Options; namespace Koogle.Web.Store.GameState; @@ -24,6 +25,7 @@ public class GameEffects private readonly GameDefinitionRegistry _gameRegistry; private readonly GameHubService _hubService; private readonly IOptions _appSettings; + private readonly IGameEventService _gameEventService; // Debounce timer for save operations private Timer? _saveDebounceTimer; @@ -39,7 +41,8 @@ public class GameEffects ICurrentClubContext clubContext, GameDefinitionRegistry gameRegistry, GameHubService hubService, - IOptions appSettings) + IOptions appSettings, + IGameEventService gameEventService) { _logger = logger; _gameState = gameState; @@ -48,6 +51,7 @@ public class GameEffects _gameRegistry = gameRegistry; _hubService = hubService; _appSettings = appSettings; + _gameEventService = gameEventService; } /// @@ -295,9 +299,10 @@ public class GameEffects /// /// Handles RecordThrowAction - calls ProcessThrow and applies game logic. + /// Also fires expense triggers for special events (Gutter, Strike, etc.). /// [EffectMethod] - public Task HandleRecordThrow(RecordThrowAction action, IDispatcher dispatcher) + public async Task HandleRecordThrow(RecordThrowAction action, IDispatcher dispatcher) { var state = _gameState.Value; var gameTypeName = state.GameTypeName; @@ -307,13 +312,13 @@ public class GameEffects if (state.IsGameOver) { _logger.LogWarning("Cannot process throw: game is already over"); - return Task.CompletedTask; + return; } if (string.IsNullOrEmpty(gameTypeName) || !currentPlayerId.HasValue) { _logger.LogWarning("Cannot process throw: missing game type or player"); - return Task.CompletedTask; + return; } // Get game logic service (may be null if game type not registered) @@ -414,10 +419,13 @@ public class GameEffects isGameOver, winnerId)); + // Fire expense triggers for special throw events + await FireThrowTriggersAsync(state, currentPlayerId.Value, action, afterThrowState, dispatcher); + // If game is over, skip save (user must confirm end via UI) if (isGameOver) { - return Task.CompletedTask; + return; } // Debounce save operations to avoid excessive DB writes @@ -427,8 +435,6 @@ public class GameEffects null, SaveDebounceMs, Timeout.Infinite); - - return Task.CompletedTask; } /// @@ -825,4 +831,141 @@ public class GameEffects CurrentPlayerIndex = dto.CurrentPlayerIndex, Mode = (ParticipantsMode)dto.Mode }; + + /// + /// Fires expense triggers based on throw events (Gutter, Strike, Circle, etc.). + /// Dispatches TriggerExpensesCreatedAction to update UI. + /// + private async Task FireThrowTriggersAsync( + GameState state, + Guid currentPlayerId, + RecordThrowAction action, + AfterThrowState afterThrowState, + IDispatcher dispatcher) + { + // Skip if no day or game context + if (!state.DayId.HasValue || !state.ActiveGameId.HasValue) + { + _logger.LogDebug("Skipping trigger fire: missing DayId or GameId"); + return; + } + + var dayId = state.DayId.Value; + var gameId = state.ActiveGameId.Value; + var allParticipantIds = state.Participants.PlayerIds.ToList(); + var allCreatedExpenses = new List(); + + try + { + // Check for Gutter + if (action.IsGutter) + { + // Check if this is the first throw of the round (Anwurf) + bool isFirstThrow = afterThrowState.ThrowPanel.ThrowCounterPerRound == 1; + + var expenses = await _gameEventService.RegisterGutterAsync( + currentPlayerId, + dayId, + gameId, + allParticipantIds, + isFirstThrow); + + allCreatedExpenses.AddRange(expenses); + + if (expenses.Count > 0) + { + _logger.LogInformation( + "Gutter trigger fired: {Count} expenses assigned to player {PlayerId}", + expenses.Count, currentPlayerId); + } + } + + // Check for Strike (alle Neune) + if (afterThrowState.IsStrike) + { + var expenses = await _gameEventService.RegisterStrikeAsync( + currentPlayerId, + dayId, + gameId, + allParticipantIds); + + allCreatedExpenses.AddRange(expenses); + + if (expenses.Count > 0) + { + _logger.LogInformation( + "Strike trigger fired: {Count} expenses assigned", + expenses.Count); + } + } + + // Check for Circle (Kranz) + if (afterThrowState.IsCircle) + { + var expenses = await _gameEventService.RegisterCircleAsync( + currentPlayerId, + dayId, + gameId, + allParticipantIds); + + allCreatedExpenses.AddRange(expenses); + + if (expenses.Count > 0) + { + _logger.LogInformation( + "Circle trigger fired: {Count} expenses assigned", + expenses.Count); + } + } + + // Check for No Wood (kein Holz) - 0 pins knocked but not a gutter + if (afterThrowState.PinsKnocked == 0 && !action.IsGutter) + { + var expenses = await _gameEventService.RegisterNoWoodAsync( + currentPlayerId, + dayId, + gameId, + allParticipantIds); + + allCreatedExpenses.AddRange(expenses); + + if (expenses.Count > 0) + { + _logger.LogInformation( + "NoWood trigger fired: {Count} expenses assigned", + expenses.Count); + } + } + + // Check for Bell hit + if (afterThrowState.ThrowPanel.BellValue) + { + var expenses = await _gameEventService.RegisterBellAsync( + currentPlayerId, + dayId, + gameId, + allParticipantIds); + + allCreatedExpenses.AddRange(expenses); + + if (expenses.Count > 0) + { + _logger.LogInformation( + "Bell trigger fired: {Count} expenses assigned", + expenses.Count); + } + } + + // Dispatch action to update UI with all created expenses + if (allCreatedExpenses.Count > 0) + { + dispatcher.Dispatch(new DayState.TriggerExpensesCreatedAction(allCreatedExpenses)); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error firing throw triggers for player {PlayerId}", currentPlayerId); + // Don't fail the throw recording if trigger fails + } + } }