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:
parent
dbb59ed54f
commit
fc97a266d4
|
|
@ -35,6 +35,7 @@ namespace Koogle.Application
|
||||||
services.AddScoped<IDashboardService, DashboardService>();
|
services.AddScoped<IDashboardService, DashboardService>();
|
||||||
services.AddScoped<IEmailService, StubEmailService>();
|
services.AddScoped<IEmailService, StubEmailService>();
|
||||||
services.AddScoped<ITriggerService, TriggerService>();
|
services.AddScoped<ITriggerService, TriggerService>();
|
||||||
|
services.AddScoped<IGameEventService, GameEventService>();
|
||||||
services.AddScoped<IGamePersistenceService, GamePersistenceService>();
|
services.AddScoped<IGamePersistenceService, GamePersistenceService>();
|
||||||
|
|
||||||
// Note: Game types are registered in Koogle.Web where Blazor components are defined
|
// Note: Game types are registered in Koogle.Web where Blazor components are defined
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -33,19 +33,22 @@ public interface ITriggerService
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fires a trigger, creating PersonExpenses for all linked expenses.
|
/// Fires a trigger, creating PersonExpenses for all linked expenses.
|
||||||
|
/// For inverse expenses, charges all participants except the triggering person.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="type">The trigger type (e.g., ExpensePoint, Circle, Strike).</param>
|
/// <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="dayId">The day context for the expense.</param>
|
||||||
/// <param name="gameId">Optional game 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="multiplier">Multiplier for expense price (e.g., remaining points in Scheiss-Spiel). Default is 1.</param>
|
||||||
/// <param name="ct">Cancellation token.</param>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
/// <returns>A list of created PersonExpense DTOs.</returns>
|
/// <returns>A list of created PersonExpense DTOs.</returns>
|
||||||
Task<List<PersonExpenseDto>> FireTriggerAsync(
|
Task<List<PersonExpenseDto>> FireTriggerAsync(
|
||||||
ExpenseTriggerType type,
|
ExpenseTriggerType type,
|
||||||
Guid personId,
|
Guid triggeringPersonId,
|
||||||
Guid dayId,
|
Guid dayId,
|
||||||
Guid? gameId = null,
|
Guid? gameId = null,
|
||||||
|
IReadOnlyList<Guid>? allParticipantIds = null,
|
||||||
int multiplier = 1,
|
int multiplier = 1,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -82,9 +82,10 @@ public class TriggerService : ITriggerService
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<List<PersonExpenseDto>> FireTriggerAsync(
|
public async Task<List<PersonExpenseDto>> FireTriggerAsync(
|
||||||
ExpenseTriggerType type,
|
ExpenseTriggerType type,
|
||||||
Guid personId,
|
Guid triggeringPersonId,
|
||||||
Guid dayId,
|
Guid dayId,
|
||||||
Guid? gameId = null,
|
Guid? gameId = null,
|
||||||
|
IReadOnlyList<Guid>? allParticipantIds = null,
|
||||||
int multiplier = 1,
|
int multiplier = 1,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|
@ -105,11 +106,11 @@ public class TriggerService : ITriggerService
|
||||||
if (day.Status == DayStatus.Closed)
|
if (day.Status == DayStatus.Closed)
|
||||||
throw new InvalidOperationException("Cannot add expenses to a closed day.");
|
throw new InvalidOperationException("Cannot add expenses to a closed day.");
|
||||||
|
|
||||||
// Validate person exists
|
// Validate triggering person exists
|
||||||
var person = await context.Persons
|
var triggeringPerson = await context.Persons
|
||||||
.Where(p => p.Id == personId && p.ClubId == _clubContext.ClubId && !p.IsDeleted)
|
.Where(p => p.Id == triggeringPersonId && p.ClubId == _clubContext.ClubId && !p.IsDeleted)
|
||||||
.FirstOrDefaultAsync(ct)
|
.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>();
|
var createdExpenses = new List<PersonExpense>();
|
||||||
|
|
||||||
|
|
@ -118,10 +119,30 @@ public class TriggerService : ITriggerService
|
||||||
// Calculate price with multiplier
|
// Calculate price with multiplier
|
||||||
var price = expense.Price * multiplier;
|
var price = expense.Price * multiplier;
|
||||||
|
|
||||||
|
// Determine target persons based on IsInverse flag
|
||||||
|
IEnumerable<Guid> targetPersonIds;
|
||||||
|
if (expense.IsInverse)
|
||||||
|
{
|
||||||
|
// 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var targetPersonId in targetPersonIds)
|
||||||
|
{
|
||||||
var personExpense = new PersonExpense
|
var personExpense = new PersonExpense
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
PersonId = personId,
|
PersonId = targetPersonId,
|
||||||
ExpenseId = expense.Id,
|
ExpenseId = expense.Id,
|
||||||
DayId = dayId,
|
DayId = dayId,
|
||||||
GameId = gameId,
|
GameId = gameId,
|
||||||
|
|
@ -138,6 +159,10 @@ public class TriggerService : ITriggerService
|
||||||
context.PersonExpenses.Add(personExpense);
|
context.PersonExpenses.Add(personExpense);
|
||||||
createdExpenses.Add(personExpense);
|
createdExpenses.Add(personExpense);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdExpenses.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
await context.SaveChangesAsync(ct);
|
await context.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -261,3 +261,8 @@ public record UpdatePersonExpenseStatusSuccessAction(PersonExpenseDto Expense);
|
||||||
/// Action dispatched when updating person expense status fails.
|
/// Action dispatched when updating person expense status fails.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record UpdatePersonExpenseStatusFailureAction(string Error);
|
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);
|
||||||
|
|
|
||||||
|
|
@ -555,6 +555,16 @@ public static class DayReducers
|
||||||
Error = action.Error
|
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>
|
/// <summary>
|
||||||
/// Handles DeletePersonExpenseAction - sets loading state.
|
/// Handles DeletePersonExpenseAction - sets loading state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ using Koogle.Domain.Enums;
|
||||||
using Koogle.Infrastructure.Configuration;
|
using Koogle.Infrastructure.Configuration;
|
||||||
using Koogle.Web.Hubs;
|
using Koogle.Web.Hubs;
|
||||||
using Koogle.Web.Services;
|
using Koogle.Web.Services;
|
||||||
|
using Koogle.Web.Store.DayState;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Koogle.Web.Store.GameState;
|
namespace Koogle.Web.Store.GameState;
|
||||||
|
|
@ -24,6 +25,7 @@ public class GameEffects
|
||||||
private readonly GameDefinitionRegistry _gameRegistry;
|
private readonly GameDefinitionRegistry _gameRegistry;
|
||||||
private readonly GameHubService _hubService;
|
private readonly GameHubService _hubService;
|
||||||
private readonly IOptions<AppSettings> _appSettings;
|
private readonly IOptions<AppSettings> _appSettings;
|
||||||
|
private readonly IGameEventService _gameEventService;
|
||||||
|
|
||||||
// Debounce timer for save operations
|
// Debounce timer for save operations
|
||||||
private Timer? _saveDebounceTimer;
|
private Timer? _saveDebounceTimer;
|
||||||
|
|
@ -39,7 +41,8 @@ public class GameEffects
|
||||||
ICurrentClubContext clubContext,
|
ICurrentClubContext clubContext,
|
||||||
GameDefinitionRegistry gameRegistry,
|
GameDefinitionRegistry gameRegistry,
|
||||||
GameHubService hubService,
|
GameHubService hubService,
|
||||||
IOptions<AppSettings> appSettings)
|
IOptions<AppSettings> appSettings,
|
||||||
|
IGameEventService gameEventService)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_gameState = gameState;
|
_gameState = gameState;
|
||||||
|
|
@ -48,6 +51,7 @@ public class GameEffects
|
||||||
_gameRegistry = gameRegistry;
|
_gameRegistry = gameRegistry;
|
||||||
_hubService = hubService;
|
_hubService = hubService;
|
||||||
_appSettings = appSettings;
|
_appSettings = appSettings;
|
||||||
|
_gameEventService = gameEventService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -295,9 +299,10 @@ public class GameEffects
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles RecordThrowAction - calls ProcessThrow and applies game logic.
|
/// Handles RecordThrowAction - calls ProcessThrow and applies game logic.
|
||||||
|
/// Also fires expense triggers for special events (Gutter, Strike, etc.).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[EffectMethod]
|
[EffectMethod]
|
||||||
public Task HandleRecordThrow(RecordThrowAction action, IDispatcher dispatcher)
|
public async Task HandleRecordThrow(RecordThrowAction action, IDispatcher dispatcher)
|
||||||
{
|
{
|
||||||
var state = _gameState.Value;
|
var state = _gameState.Value;
|
||||||
var gameTypeName = state.GameTypeName;
|
var gameTypeName = state.GameTypeName;
|
||||||
|
|
@ -307,13 +312,13 @@ public class GameEffects
|
||||||
if (state.IsGameOver)
|
if (state.IsGameOver)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Cannot process throw: game is already over");
|
_logger.LogWarning("Cannot process throw: game is already over");
|
||||||
return Task.CompletedTask;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(gameTypeName) || !currentPlayerId.HasValue)
|
if (string.IsNullOrEmpty(gameTypeName) || !currentPlayerId.HasValue)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Cannot process throw: missing game type or player");
|
_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)
|
// Get game logic service (may be null if game type not registered)
|
||||||
|
|
@ -414,10 +419,13 @@ public class GameEffects
|
||||||
isGameOver,
|
isGameOver,
|
||||||
winnerId));
|
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 game is over, skip save (user must confirm end via UI)
|
||||||
if (isGameOver)
|
if (isGameOver)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounce save operations to avoid excessive DB writes
|
// Debounce save operations to avoid excessive DB writes
|
||||||
|
|
@ -427,8 +435,6 @@ public class GameEffects
|
||||||
null,
|
null,
|
||||||
SaveDebounceMs,
|
SaveDebounceMs,
|
||||||
Timeout.Infinite);
|
Timeout.Infinite);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -825,4 +831,141 @@ public class GameEffects
|
||||||
CurrentPlayerIndex = dto.CurrentPlayerIndex,
|
CurrentPlayerIndex = dto.CurrentPlayerIndex,
|
||||||
Mode = (ParticipantsMode)dto.Mode
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue