call gamelogic

This commit is contained in:
beo3000 2025-12-27 14:39:15 +01:00
parent c33a6f9d91
commit f297317b71
5 changed files with 302 additions and 44 deletions

View File

@ -196,6 +196,38 @@ public record ThrowResult
/// Trigger events that should fire (e.g., for penalties).
/// </summary>
public IReadOnlyList<TriggerEvent> Triggers { get; init; } = [];
/// <summary>
/// Optional overrides for standard lane behavior.
/// If null, standard behavior applies (pins reset based on ThrowMode after round).
/// </summary>
public GameLogicOverrides? Overrides { get; init; }
}
/// <summary>
/// Optional overrides for standard lane behavior.
/// Game logic can use these to control next player or pin pattern.
/// </summary>
public record GameLogicOverrides
{
/// <summary>
/// Override the next player. If set, this player will throw next instead of
/// following the standard rotation.
/// </summary>
public Guid? NextPlayerId { get; init; }
/// <summary>
/// Override the pin pattern for the next throw. If set, these pin statuses
/// will be used instead of the standard reset behavior.
/// Array of 9 PinStatus values (index 0 = pin 1, etc.).
/// </summary>
public PinStatus[]? PinPattern { get; init; }
/// <summary>
/// If true, skip the standard pin reset after round completion.
/// Use this when PinPattern provides a custom configuration.
/// </summary>
public bool SkipStandardPinReset { get; init; }
}
/// <summary>

View File

@ -2,6 +2,7 @@
@using Koogle.Web.Store.GameState
@using Koogle.Web.Components.Game
@inherits FluxorComponent
@implements IDisposable
@inject IState<GameState> GameState
@inject IDispatcher Dispatcher
@ -163,9 +164,37 @@
private int? _selectedNumber;
private bool _hasModifiedPins;
private ThrowPanelState? _beforeThrowState;
private bool IsInteractive => GameState.Value.IsGameActive && !GameState.Value.IsLoading;
protected override void OnInitialized()
{
base.OnInitialized();
GameState.StateChanged += OnGameStateChanged;
// Store initial state for before/after comparison
CaptureBeforeThrowState();
}
private void OnGameStateChanged(object? sender, EventArgs e)
{
// After throw is processed and pins are reset, capture new before state
if (!_hasModifiedPins)
{
CaptureBeforeThrowState();
}
}
private void CaptureBeforeThrowState()
{
_beforeThrowState = GameState.Value.ThrowPanel;
}
public void Dispose()
{
GameState.StateChanged -= OnGameStateChanged;
}
// Throw can always be confirmed - no pins marked = "Kein Holz" (0 fallen)
private bool CanConfirmThrow => true;
@ -229,59 +258,31 @@
private async Task ConfirmThrow(bool isGutter, bool isLeftGutter)
{
var fallenPins = GameState.Value.ThrowPanel.CountFallenPins();
var bellValue = GameState.Value.ThrowPanel.BellValue;
var currentThrowPanel = GameState.Value.ThrowPanel;
var fallenPins = currentThrowPanel.CountFallenPins();
var bellValue = currentThrowPanel.BellValue;
// Create throw result
// Create throw result for UI callback
var result = new ThrowResult
{
FallenPins = fallenPins,
IsGutter = isGutter,
IsLeftGutter = isLeftGutter,
BellValue = bellValue,
PinStates = GameState.Value.ThrowPanel.GetPins()
PinStates = currentThrowPanel.GetPins()
};
// Calculate new throw panel state
var newThrowPanel = GameState.Value.ThrowPanel with
{
ThrowCounterPerRound = GameState.Value.ThrowPanel.ThrowCounterPerRound + 1,
TotalThrowCounter = GameState.Value.ThrowPanel.TotalThrowCounter + 1,
BellValue = false // Reset bell after throw
};
// Get before state (captured at start of input session)
var beforeState = _beforeThrowState ?? currentThrowPanel;
// Check if round is complete
if (newThrowPanel.ThrowCounterPerRound >= newThrowPanel.ThrowsPerRound)
{
newThrowPanel = newThrowPanel with { ThrowCounterPerRound = 0 };
// Reset all pins at end of round (both modes)
newThrowPanel = newThrowPanel.ResetPins();
}
else if (newThrowPanel.ThrowMode == ThrowMode.Reposition)
{
// In Reposition mode, always reset pins after each throw
newThrowPanel = newThrowPanel.ResetPins();
}
else if (newThrowPanel.ThrowMode == ThrowMode.Decrease)
{
// In Decrease mode: Mark fallen pins as disabled (not available for next throw)
newThrowPanel = newThrowPanel.MarkFallenAsDisabled();
// If all pins are down, reset all pins for a fresh set
if (newThrowPanel.AllPinsDown())
{
newThrowPanel = newThrowPanel.ResetPins();
}
}
// Dispatch record throw action
Dispatcher.Dispatch(new RecordThrowAction(newThrowPanel, GameState.Value.GameModel));
// Dispatch record throw action - game logic will handle pin reset and player rotation
Dispatcher.Dispatch(new RecordThrowAction(beforeState, currentThrowPanel));
// Reset local state
_selectedNumber = null;
_hasModifiedPins = false;
// Notify parent
// Notify parent (for timer/tab switching)
await OnThrowCompleted.InvokeAsync(result);
}

View File

@ -96,7 +96,28 @@ public record LoadCompletedGamesFailureAction(string Error);
/// <summary>
/// Action to record a throw with pin results.
/// </summary>
public record RecordThrowAction(ThrowPanelState NewThrowPanelState, object? UpdatedGameModel);
/// <param name="BeforeThrowState">State before the throw was made.</param>
/// <param name="AfterThrowState">State after the throw was made.</param>
public record RecordThrowAction(ThrowPanelState BeforeThrowState, ThrowPanelState AfterThrowState);
/// <summary>
/// Action dispatched after ProcessThrow completes with game logic result.
/// </summary>
/// <param name="NewThrowPanelState">Updated throw panel state.</param>
/// <param name="UpdatedGameModel">Updated game model from ProcessThrow.</param>
/// <param name="ShouldRotatePlayer">Whether to rotate to next player.</param>
/// <param name="NextPlayerId">Override: specific player to set as current.</param>
/// <param name="PinPattern">Override: custom pin pattern for next throw.</param>
/// <param name="IsGameOver">Whether the game has ended.</param>
/// <param name="WinnerId">Winner if game is over.</param>
public record ProcessThrowResultAction(
ThrowPanelState NewThrowPanelState,
object? UpdatedGameModel,
bool ShouldRotatePlayer,
Guid? NextPlayerId,
PinStatus[]? PinPattern,
bool IsGameOver,
Guid? WinnerId);
/// <summary>
/// Action dispatched when throw is recorded successfully.

View File

@ -17,6 +17,7 @@ public class GameEffects
private readonly IState<GameState> _gameState;
private readonly IGamePersistenceService _persistenceService;
private readonly ICurrentClubContext _clubContext;
private readonly GameDefinitionRegistry _gameRegistry;
// Debounce timer for save operations
private Timer? _saveDebounceTimer;
@ -29,12 +30,14 @@ public class GameEffects
ILogger<GameEffects> logger,
IState<GameState> gameState,
IGamePersistenceService persistenceService,
ICurrentClubContext clubContext)
ICurrentClubContext clubContext,
GameDefinitionRegistry gameRegistry)
{
_logger = logger;
_gameState = gameState;
_persistenceService = persistenceService;
_clubContext = clubContext;
_gameRegistry = gameRegistry;
}
/// <summary>
@ -246,11 +249,121 @@ public class GameEffects
}
/// <summary>
/// Handles RecordThrowAction - triggers debounced save to database.
/// Handles RecordThrowAction - calls ProcessThrow and applies game logic.
/// </summary>
[EffectMethod]
public Task HandleRecordThrow(RecordThrowAction action, IDispatcher dispatcher)
{
var state = _gameState.Value;
var gameTypeName = state.GameTypeName;
var currentPlayerId = state.Participants.CurrentPlayerId;
if (string.IsNullOrEmpty(gameTypeName) || !currentPlayerId.HasValue)
{
_logger.LogWarning("Cannot process throw: missing game type or player");
return Task.CompletedTask;
}
// Get game logic service (may be null if game type not registered)
IGameLogicService? gameLogicService = null;
try
{
gameLogicService = _gameRegistry.GetLogicService(gameTypeName);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not resolve game logic service for {GameType}", gameTypeName);
}
// Create AfterThrowState for game logic
var beforeSnapshot = CreateThrowPanelSnapshot(action.BeforeThrowState);
var afterSnapshot = CreateThrowPanelSnapshot(action.AfterThrowState);
var afterThrowState = afterSnapshot.CreateAfterThrowState(beforeSnapshot, currentPlayerId.Value);
// Calculate new throw panel state with standard lane behavior
var newThrowPanel = action.AfterThrowState with
{
ThrowCounterPerRound = action.AfterThrowState.ThrowCounterPerRound + 1,
TotalThrowCounter = action.AfterThrowState.TotalThrowCounter + 1,
BellValue = false // Reset bell after throw
};
// Default values
bool shouldRotatePlayer = false;
Guid? nextPlayerId = null;
PinStatus[]? pinPattern = null;
bool isGameOver = false;
Guid? winnerId = null;
object? updatedGameModel = state.GameModel;
// Call ProcessThrow if game logic service is available
if (gameLogicService != null && state.GameModel != null)
{
try
{
var (updatedModel, throwResult) = gameLogicService.ProcessThrow(state.GameModel, afterThrowState);
updatedGameModel = updatedModel;
shouldRotatePlayer = throwResult.ShouldRotatePlayer;
isGameOver = throwResult.IsGameOver;
winnerId = throwResult.WinnerId;
// Check for overrides from game logic
if (throwResult.Overrides != null)
{
nextPlayerId = throwResult.Overrides.NextPlayerId;
pinPattern = throwResult.Overrides.PinPattern;
// If game logic provides pin pattern, use it
if (pinPattern != null && pinPattern.Length == 9)
{
newThrowPanel = ApplyPinPattern(newThrowPanel, pinPattern);
}
else if (!throwResult.Overrides.SkipStandardPinReset)
{
// Apply standard pin reset behavior
newThrowPanel = ApplyStandardPinReset(newThrowPanel);
}
}
else
{
// No overrides - apply standard pin reset behavior
newThrowPanel = ApplyStandardPinReset(newThrowPanel);
}
_logger.LogDebug(
"ProcessThrow completed: pins={Pins}, rotate={Rotate}, gameOver={GameOver}",
afterThrowState.PinsKnocked, shouldRotatePlayer, isGameOver);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in ProcessThrow for game type {GameType}", gameTypeName);
// Continue with standard behavior on error
newThrowPanel = ApplyStandardPinReset(newThrowPanel);
}
}
else
{
// No game logic service - use standard behavior
newThrowPanel = ApplyStandardPinReset(newThrowPanel);
// Standard rotation: rotate after round is complete
shouldRotatePlayer = newThrowPanel.ThrowCounterPerRound >= newThrowPanel.ThrowsPerRound;
if (shouldRotatePlayer)
{
newThrowPanel = newThrowPanel with { ThrowCounterPerRound = 0 };
}
}
// Dispatch result action
dispatcher.Dispatch(new ProcessThrowResultAction(
newThrowPanel,
updatedGameModel,
shouldRotatePlayer,
nextPlayerId,
pinPattern,
isGameOver,
winnerId));
// Debounce save operations to avoid excessive DB writes
_saveDebounceTimer?.Dispose();
_saveDebounceTimer = new Timer(
@ -262,6 +375,64 @@ public class GameEffects
return Task.CompletedTask;
}
private static ThrowPanelSnapshot CreateThrowPanelSnapshot(ThrowPanelState state)
{
return ThrowPanelSnapshot.FromPins(
state.GetPins(),
state.ThrowsPerRound,
state.ThrowCounterPerRound,
state.TotalThrowCounter,
state.ThrowMode,
state.BellValue);
}
private static ThrowPanelState ApplyPinPattern(ThrowPanelState state, PinStatus[] pattern)
{
return state with
{
Pin1 = pattern[0],
Pin2 = pattern[1],
Pin3 = pattern[2],
Pin4 = pattern[3],
Pin5 = pattern[4],
Pin6 = pattern[5],
Pin7 = pattern[6],
Pin8 = pattern[7],
Pin9 = pattern[8]
};
}
private static ThrowPanelState ApplyStandardPinReset(ThrowPanelState state)
{
// Check if round is complete
if (state.ThrowCounterPerRound >= state.ThrowsPerRound)
{
// Reset throw counter and all pins at end of round
var resetState = state with { ThrowCounterPerRound = 0 };
return resetState.ResetPins();
}
// Within round - behavior depends on throw mode
if (state.ThrowMode == ThrowMode.Reposition)
{
// Reposition: reset pins after each throw
return state.ResetPins();
}
if (state.ThrowMode == ThrowMode.Decrease)
{
// Decrease: mark fallen as disabled, check for all-down reset
var newState = state.MarkFallenAsDisabled();
if (newState.AllPinsDown())
{
return newState.ResetPins();
}
return newState;
}
return state;
}
/// <summary>
/// Handles UndoThrowSuccessAction - triggers debounced save after undo.
/// </summary>

View File

@ -181,12 +181,12 @@ public static class GameReducers
// Throw Reducers
/// <summary>
/// Handles RecordThrowAction - updates state and adds snapshot to undo stack.
/// Handles RecordThrowAction - creates undo snapshot before effect processes throw.
/// </summary>
[ReducerMethod]
public static GameState OnRecordThrow(GameState state, RecordThrowAction action)
{
// Create snapshot of current state before applying throw
// Create snapshot of current state before throw is processed
var snapshot = new GameSnapshot
{
ThrowPanel = state.ThrowPanel,
@ -195,12 +195,45 @@ public static class GameReducers
};
return state with
{
UndoStack = state.UndoStack.Add(snapshot),
IsLoading = true
};
}
/// <summary>
/// Handles ProcessThrowResultAction - applies game logic result with optional overrides.
/// </summary>
[ReducerMethod]
public static GameState OnProcessThrowResult(GameState state, ProcessThrowResultAction action)
{
var newState = state with
{
ThrowPanel = action.NewThrowPanelState,
GameModel = action.UpdatedGameModel,
UndoStack = state.UndoStack.Add(snapshot),
IsLoading = false,
IsSaving = true
};
// Apply player rotation or override
if (action.NextPlayerId.HasValue)
{
// Game logic specifies exact next player
newState = newState with
{
Participants = newState.Participants.SetCurrentPlayer(action.NextPlayerId.Value)
};
}
else if (action.ShouldRotatePlayer)
{
// Standard rotation to next player
newState = newState with
{
Participants = newState.Participants.NextPlayer()
};
}
return newState;
}
/// <summary>