create and assign expenses:

Datenfluss bei Gutter:
  Spieler wirft Gosse
    → RecordThrowAction (IsGutter=true)
    → HandleRecordThrow
    → FireThrowTriggersAsync
      → GameEventService.RegisterGutterAsync
        → TriggerService.FireTriggerAsync
          → PersonExpense in DB gespeichert ✓
          → PersonExpenseDto zurückgegeben
      → dispatcher.Dispatch(TriggerExpensesCreatedAction)
        → DayReducer fügt Expenses zu SelectedDayExpenses hinzu
          → UI aktualisiert sich automatisch ✓
This commit is contained in:
beo3000 2025-12-28 15:41:57 +01:00
parent dbb59ed54f
commit fc97a266d4
8 changed files with 510 additions and 31 deletions

View File

@ -35,6 +35,7 @@ namespace Koogle.Application
services.AddScoped<IDashboardService, DashboardService>();
services.AddScoped<IEmailService, StubEmailService>();
services.AddScoped<ITriggerService, TriggerService>();
services.AddScoped<IGameEventService, GameEventService>();
services.AddScoped<IGamePersistenceService, GamePersistenceService>();
// Note: Game types are registered in Koogle.Web where Blazor components are defined

View File

@ -0,0 +1,108 @@
using Koogle.Application.DTOs;
namespace Koogle.Application.Interfaces;
/// <summary>
/// Service for handling game events that may trigger automatic expense assignments.
/// Supports inverse expenses (charged to all other participants).
/// </summary>
public interface IGameEventService
{
/// <summary>
/// Registers a gutter throw (left or right) and fires associated triggers.
/// </summary>
/// <param name="personId">The player who threw the gutter.</param>
/// <param name="dayId">The current day.</param>
/// <param name="gameId">The current game.</param>
/// <param name="allParticipantIds">All participant IDs (required for inverse expenses).</param>
/// <param name="isFirstThrow">True if this is the first throw (Anwurf).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of assigned expenses.</returns>
Task<List<PersonExpenseDto>> RegisterGutterAsync(
Guid personId,
Guid dayId,
Guid gameId,
IReadOnlyList<Guid> allParticipantIds,
bool isFirstThrow = false,
CancellationToken ct = default);
/// <summary>
/// Registers a strike (alle Neune) and fires associated triggers.
/// </summary>
Task<List<PersonExpenseDto>> RegisterStrikeAsync(
Guid personId,
Guid dayId,
Guid gameId,
IReadOnlyList<Guid> allParticipantIds,
CancellationToken ct = default);
/// <summary>
/// Registers a bell hit (Klingel) and fires associated triggers.
/// </summary>
Task<List<PersonExpenseDto>> RegisterBellAsync(
Guid personId,
Guid dayId,
Guid gameId,
IReadOnlyList<Guid> allParticipantIds,
CancellationToken ct = default);
/// <summary>
/// Registers a circle (Kranz) and fires associated triggers.
/// </summary>
Task<List<PersonExpenseDto>> RegisterCircleAsync(
Guid personId,
Guid dayId,
Guid gameId,
IReadOnlyList<Guid> allParticipantIds,
CancellationToken ct = default);
/// <summary>
/// Registers a no-wood throw (kein Holz) and fires associated triggers.
/// </summary>
Task<List<PersonExpenseDto>> RegisterNoWoodAsync(
Guid personId,
Guid dayId,
Guid gameId,
IReadOnlyList<Guid> allParticipantIds,
CancellationToken ct = default);
/// <summary>
/// Registers player elimination and fires associated triggers.
/// </summary>
Task<List<PersonExpenseDto>> RegisterEliminatedAsync(
Guid personId,
Guid dayId,
Guid gameId,
IReadOnlyList<Guid> allParticipantIds,
CancellationToken ct = default);
/// <summary>
/// Registers expense points (e.g., Scheißspiel points) with multiplier.
/// </summary>
/// <param name="personId">The player receiving points.</param>
/// <param name="dayId">The current day.</param>
/// <param name="gameId">The current game.</param>
/// <param name="allParticipantIds">All participant IDs (required for inverse expenses).</param>
/// <param name="points">Number of expense points (multiplier).</param>
/// <param name="ct">Cancellation token.</param>
Task<List<PersonExpenseDto>> RegisterExpensePointsAsync(
Guid personId,
Guid dayId,
Guid gameId,
IReadOnlyList<Guid> allParticipantIds,
int points,
CancellationToken ct = default);
/// <summary>
/// Registers player absence on match day.
/// </summary>
/// <param name="personId">The absent player.</param>
/// <param name="dayId">The current day.</param>
/// <param name="allParticipantIds">All participant IDs (required for inverse expenses).</param>
/// <param name="ct">Cancellation token.</param>
Task<List<PersonExpenseDto>> RegisterAbsentAsync(
Guid personId,
Guid dayId,
IReadOnlyList<Guid> allParticipantIds,
CancellationToken ct = default);
}

View File

@ -33,19 +33,22 @@ public interface ITriggerService
/// <summary>
/// Fires a trigger, creating PersonExpenses for all linked expenses.
/// For inverse expenses, charges all participants except the triggering person.
/// </summary>
/// <param name="type">The trigger type (e.g., ExpensePoint, Circle, Strike).</param>
/// <param name="personId">The person receiving the expense(s).</param>
/// <param name="triggeringPersonId">The person who triggered the event.</param>
/// <param name="dayId">The day context for the expense.</param>
/// <param name="gameId">Optional game context for the expense.</param>
/// <param name="allParticipantIds">All participant IDs (required for inverse expenses).</param>
/// <param name="multiplier">Multiplier for expense price (e.g., remaining points in Scheiss-Spiel). Default is 1.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A list of created PersonExpense DTOs.</returns>
Task<List<PersonExpenseDto>> FireTriggerAsync(
ExpenseTriggerType type,
Guid personId,
Guid triggeringPersonId,
Guid dayId,
Guid? gameId = null,
IReadOnlyList<Guid>? allParticipantIds = null,
int multiplier = 1,
CancellationToken ct = default);

View File

@ -0,0 +1,184 @@
using Koogle.Application.DTOs;
using Koogle.Application.Interfaces;
using Koogle.Domain.Enums;
namespace Koogle.Application.Services;
/// <summary>
/// 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.
/// </summary>
public class GameEventService : IGameEventService
{
private readonly ITriggerService _triggerService;
public GameEventService(ITriggerService triggerService)
{
_triggerService = triggerService;
}
/// <inheritdoc />
public async Task<List<PersonExpenseDto>> RegisterGutterAsync(
Guid personId,
Guid dayId,
Guid gameId,
IReadOnlyList<Guid> allParticipantIds,
bool isFirstThrow = false,
CancellationToken ct = default)
{
var expenses = new List<PersonExpenseDto>();
// 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;
}
/// <inheritdoc />
public async Task<List<PersonExpenseDto>> RegisterStrikeAsync(
Guid personId,
Guid dayId,
Guid gameId,
IReadOnlyList<Guid> 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);
}
/// <inheritdoc />
public async Task<List<PersonExpenseDto>> RegisterBellAsync(
Guid personId,
Guid dayId,
Guid gameId,
IReadOnlyList<Guid> allParticipantIds,
CancellationToken ct = default)
{
// Bell hit = "Klingel getroffen"
return await _triggerService.FireTriggerAsync(
ExpenseTriggerType.Bell,
personId,
dayId,
gameId,
allParticipantIds,
ct: ct);
}
/// <inheritdoc />
public async Task<List<PersonExpenseDto>> RegisterCircleAsync(
Guid personId,
Guid dayId,
Guid gameId,
IReadOnlyList<Guid> allParticipantIds,
CancellationToken ct = default)
{
// Circle = "Kranz" - specific pin arrangement
return await _triggerService.FireTriggerAsync(
ExpenseTriggerType.Circle,
personId,
dayId,
gameId,
allParticipantIds,
ct: ct);
}
/// <inheritdoc />
public async Task<List<PersonExpenseDto>> RegisterNoWoodAsync(
Guid personId,
Guid dayId,
Guid gameId,
IReadOnlyList<Guid> allParticipantIds,
CancellationToken ct = default)
{
// No wood = "Kein Holz" - no pins knocked down
return await _triggerService.FireTriggerAsync(
ExpenseTriggerType.NoWood,
personId,
dayId,
gameId,
allParticipantIds,
ct: ct);
}
/// <inheritdoc />
public async Task<List<PersonExpenseDto>> RegisterEliminatedAsync(
Guid personId,
Guid dayId,
Guid gameId,
IReadOnlyList<Guid> allParticipantIds,
CancellationToken ct = default)
{
// Player eliminated from game
return await _triggerService.FireTriggerAsync(
ExpenseTriggerType.Eliminated,
personId,
dayId,
gameId,
allParticipantIds,
ct: ct);
}
/// <inheritdoc />
public async Task<List<PersonExpenseDto>> RegisterExpensePointsAsync(
Guid personId,
Guid dayId,
Guid gameId,
IReadOnlyList<Guid> 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);
}
/// <inheritdoc />
public async Task<List<PersonExpenseDto>> RegisterAbsentAsync(
Guid personId,
Guid dayId,
IReadOnlyList<Guid> 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);
}
}

View File

@ -82,9 +82,10 @@ public class TriggerService : ITriggerService
/// <inheritdoc />
public async Task<List<PersonExpenseDto>> FireTriggerAsync(
ExpenseTriggerType type,
Guid personId,
Guid triggeringPersonId,
Guid dayId,
Guid? gameId = null,
IReadOnlyList<Guid>? 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<PersonExpense>();
@ -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<Guid> 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

View File

@ -261,3 +261,8 @@ public record UpdatePersonExpenseStatusSuccessAction(PersonExpenseDto Expense);
/// Action dispatched when updating person expense status fails.
/// </summary>
public record UpdatePersonExpenseStatusFailureAction(string Error);
/// <summary>
/// Action dispatched when trigger-based expenses are created (e.g., Gutter, Strike).
/// </summary>
public record TriggerExpensesCreatedAction(IReadOnlyList<PersonExpenseDto> Expenses);

View File

@ -555,6 +555,16 @@ public static class DayReducers
Error = action.Error
};
/// <summary>
/// Handles TriggerExpensesCreatedAction - adds trigger-created expenses to list.
/// </summary>
[ReducerMethod]
public static DayState OnTriggerExpensesCreated(DayState state, TriggerExpensesCreatedAction action)
=> state with
{
SelectedDayExpenses = [.. state.SelectedDayExpenses, .. action.Expenses]
};
/// <summary>
/// Handles DeletePersonExpenseAction - sets loading state.
/// </summary>

View File

@ -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> _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> appSettings)
IOptions<AppSettings> appSettings,
IGameEventService gameEventService)
{
_logger = logger;
_gameState = gameState;
@ -48,6 +51,7 @@ public class GameEffects
_gameRegistry = gameRegistry;
_hubService = hubService;
_appSettings = appSettings;
_gameEventService = gameEventService;
}
/// <summary>
@ -295,9 +299,10 @@ public class GameEffects
/// <summary>
/// Handles RecordThrowAction - calls ProcessThrow and applies game logic.
/// Also fires expense triggers for special events (Gutter, Strike, etc.).
/// </summary>
[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;
}
/// <summary>
@ -825,4 +831,141 @@ public class GameEffects
CurrentPlayerIndex = dto.CurrentPlayerIndex,
Mode = (ParticipantsMode)dto.Mode
};
/// <summary>
/// Fires expense triggers based on throw events (Gutter, Strike, Circle, etc.).
/// Dispatches TriggerExpensesCreatedAction to update UI.
/// </summary>
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<PersonExpenseDto>();
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
}
}
}