using System.Text.Json;
using Koogle.Domain.Enums;
namespace Koogle.Application.Games.DeathBox;
///
/// Game logic service for DeathBox (Totenkisten) game.
/// Players collect marks and are eliminated when their coffin is full.
/// Last player standing wins.
///
public class DeathBoxGameLogicService : IGameLogicService
{
private static readonly Random Random = new();
///
public object CreateInitialModel(Guid[] playerIds, object? setupOptions)
{
var options = ParseSetupOptions(setupOptions);
var playerOrder = options.RandomizePlayerOrder
? playerIds.OrderBy(_ => Random.Next()).ToArray()
: playerIds.ToArray();
var playerStates = playerIds.ToDictionary(
id => id,
_ => new DeathBoxPlayerState());
return new DeathBoxGameModel
{
CoffinSize = options.CoffinSize,
PlayerStates = playerStates,
PlayerOrder = playerOrder,
CurrentPlayerIndex = 0,
PreviousPlayerId = null,
EliminatedPlayers = [],
WinnerId = null,
IsGameOver = false,
LastThrow = null
};
}
///
public (object UpdatedModel, ThrowResult Result) ProcessThrow(object gameModel, AfterThrowState afterThrow)
{
var model = CastModel(gameModel);
var playerId = afterThrow.CurrentPlayerId;
var pinsKnocked = afterThrow.PinsKnocked;
if (model.IsGameOver)
{
return (model, new ThrowResult
{
PointsScored = 0,
ShouldRotatePlayer = false,
IsGameOver = true,
WinnerId = model.WinnerId
});
}
var playerStates = new Dictionary(model.PlayerStates);
var eliminatedPlayers = new List(model.EliminatedPlayers);
var triggers = new List();
var assignX = false;
// Get current player state
var currentState = playerStates[playerId];
currentState.ThrowCount++;
currentState.TotalPins += pinsKnocked;
// Track last throw info
var lastThrow = new DeathBoxLastThrow
{
PlayerId = playerId,
PinsKnocked = pinsKnocked,
WasGutter = afterThrow.IsGutter,
WasCleared = afterThrow.IsCleared
};
// Determine if this is a new round (all 9 pins were standing before throw)
bool isNewRound = IsNewRound(afterThrow);
lastThrow = lastThrow with { IsNewRound = isNewRound };
// 1. NEW ROUND processing (when 9 pins were standing)
if (isNewRound)
{
// Always collect X on new round
//currentState.XCount++;
//lastThrow = lastThrow with { EarnedX = true };
// Penalty for <3 pins on new round
if (pinsKnocked < 3)
{
currentState.Marks++;
lastThrow = lastThrow with { WasPenalty = true };
}
// Check X conversion (3 X's -> 1 mark)
if (currentState.XCount >= 3)
{
currentState.XCount = 0;
currentState.Marks++;
lastThrow = lastThrow with { ConvertedXsToMark = true };
}
}
// 2. GUTTER / NO WOOD processing (0 pins knocked)
if (pinsKnocked == 0)
{
// Only add mark if not already penalized from new round rule
// (new round with 0 pins = already got mark from <3 rule)
if (!isNewRound)
{
currentState.Marks++;
}
if (afterThrow.IsGutter)
{
lastThrow = lastThrow with { WasGutter = true };
triggers.Add(new TriggerEvent
{
TriggerType = ExpenseTriggerType.Gutter.ToString(),
PersonId = playerId,
Multiplier = 1
});
}
else
{
lastThrow = lastThrow with { WasNoWood = true };
triggers.Add(new TriggerEvent
{
TriggerType = ExpenseTriggerType.NoWood.ToString(),
PersonId = playerId,
Multiplier = 1
});
}
}
// 3. CLEARED processing (all remaining pins hit)
//Guid? previousPlayerPenalizedId = null;
bool previousPlayerEliminated = false;
if (afterThrow.IsCleared /*&& !isNewRound*/)
{
// Current player gets egg
currentState.EggCount++;
lastThrow = lastThrow with { EarnedEgg = true };
// Check egg conversion (3 eggs -> -1 mark if possible)
if (currentState.EggCount >= 3)
{
currentState.EggCount = 0;
if (currentState.Marks > 0)
{
currentState.Marks--;
lastThrow = lastThrow with { ConvertedEggsToRemoveMark = true };
}
// Eggs expire if no marks to remove
}
// in case eggs have been earned, next player will get another X
assignX = true;
// Previous player gets mark (if exists and not eliminated)
if (model.PreviousPlayerId.HasValue &&
playerStates.TryGetValue(model.PreviousPlayerId.Value, out var prevState) &&
!prevState.IsEliminated)
{
prevState.Marks++;
lastThrow = lastThrow with
{
PreviousPlayerGotMark = true,
PreviousPlayerPenalizedId = model.PreviousPlayerId.Value
};
//previousPlayerPenalizedId = model.PreviousPlayerId.Value;
// Check if previous player is eliminated
if (prevState.Marks >= model.CoffinSize)
{
prevState.IsEliminated = true;
eliminatedPlayers.Add(model.PreviousPlayerId.Value);
previousPlayerEliminated = true;
// Calculate penalty (remaining players after this elimination)
int remainingPlayers = CountActivePlayers(playerStates);
if (remainingPlayers > 0)
{
triggers.Add(new TriggerEvent
{
TriggerType = ExpenseTriggerType.ExpensePoint.ToString(),
PersonId = model.PreviousPlayerId.Value,
Multiplier = remainingPlayers
});
}
}
}
}
lastThrow = lastThrow with { PreviousPlayerEliminated = previousPlayerEliminated };
// 4. Check ELIMINATION for current player
bool currentPlayerEliminated = false;
if (currentState.Marks >= model.CoffinSize && !currentState.IsEliminated)
{
currentState.IsEliminated = true;
eliminatedPlayers.Add(playerId);
currentPlayerEliminated = true;
lastThrow = lastThrow with { PlayerEliminated = true };
// Calculate penalty (remaining players after this elimination)
int remainingPlayers = CountActivePlayers(playerStates);
if (remainingPlayers > 0)
{
triggers.Add(new TriggerEvent
{
TriggerType = ExpenseTriggerType.ExpensePoint.ToString(),
PersonId = playerId,
Multiplier = remainingPlayers
});
}
}
// Update player states
playerStates[playerId] = currentState;
// 5. Check GAME END
bool isGameOver = false;
Guid? winnerId = null;
int activePlayersCount = CountActivePlayers(playerStates);
if (activePlayersCount <= 1)
{
isGameOver = true;
winnerId = playerStates
.Where(kvp => !kvp.Value.IsEliminated)
.Select(kvp => kvp.Key)
.FirstOrDefault();
}
// 6. Determine next player (skip eliminated players)
// Find current player's index in PlayerOrder based on actual playerId from throw
int currentIndex = Array.IndexOf(model.PlayerOrder, playerId);
if (currentIndex < 0) currentIndex = model.CurrentPlayerIndex;
int nextPlayerIndex = GetNextActivePlayerIndex(model.PlayerOrder, currentIndex, playerStates);
var nextPlayerId = model.PlayerOrder[nextPlayerIndex];
if (assignX)
{
var nextState = playerStates[nextPlayerId];
nextState.XCount ++;
lastThrow = lastThrow with { EarnedX = true };
lastThrow = lastThrow with
{
EarnedX = true,
NextPlayerPenalizedId = nextPlayerId
};
// Check X conversion (3 X's -> 1 mark)
if (nextState.XCount >= 3)
{
lastThrow = lastThrow with
{
NextPlayerGotMark = true,
NextPlayerPenalizedId = nextPlayerId
};
nextState.XCount = 0;
nextState.Marks++;
lastThrow = lastThrow with { ConvertedXsToMark = true };
}
playerStates[nextPlayerId] = nextState;
}
// Update model
model = model with
{
PlayerStates = playerStates,
EliminatedPlayers = eliminatedPlayers,
CurrentPlayerIndex = nextPlayerIndex,
PreviousPlayerId = playerId,
IsGameOver = isGameOver,
WinnerId = winnerId,
LastThrow = lastThrow
};
var result = new ThrowResult
{
PointsScored = pinsKnocked,
ShouldRotatePlayer = true,
IsGameOver = isGameOver,
WinnerId = winnerId,
Triggers = triggers,
Overrides = isGameOver ? null : new GameLogicOverrides
{
NextPlayerId = model.PlayerOrder[nextPlayerIndex]
}
};
return (model, result);
}
///
public (bool IsGameOver, Guid? WinnerId) CheckGameEnd(object gameModel)
{
var model = CastModel(gameModel);
return (model.IsGameOver, model.WinnerId);
}
///
public IReadOnlyDictionary GetPlayerStats(object gameModel)
{
var model = CastModel(gameModel);
return model.PlayerStates.ToDictionary(
kvp => kvp.Key,
kvp => new PlayerStatsSummary
{
ThrowCount = kvp.Value.ThrowCount,
PinCount = kvp.Value.TotalPins,
CircleCount = 0,
StrikeCount = 0,
CurrentPoints = kvp.Value.Marks,
CustomStats = new Dictionary
{
["Marks"] = kvp.Value.Marks,
["CoffinSize"] = model.CoffinSize,
["XCount"] = kvp.Value.XCount,
["EggCount"] = kvp.Value.EggCount,
["IsEliminated"] = kvp.Value.IsEliminated,
["IsWinner"] = model.WinnerId == kvp.Key,
["EliminationOrder"] = model.EliminatedPlayers.IndexOf(kvp.Key) + 1
}
});
}
///
public IReadOnlyList GetAvailableActions(object gameModel, Guid currentPlayerId)
{
// DeathBox has no custom actions
return [];
}
///
public GameActionResult ExecuteAction(
object gameModel,
string actionId,
Guid currentPlayerId,
IReadOnlyDictionary? parameters = null)
{
return GameActionResult.Failure($"Unknown action: {actionId}");
}
///
public GameSetupValidationResult ValidateSetup(object? setupOptions)
{
if (setupOptions is null)
{
return GameSetupValidationResult.Valid();
}
var options = ParseSetupOptions(setupOptions);
var errors = new List();
if (options.CoffinSize < 6 || options.CoffinSize > 12)
{
errors.Add("Sarggröße muss zwischen 6 und 12 liegen.");
}
return errors.Count > 0
? GameSetupValidationResult.Invalid(errors.ToArray())
: GameSetupValidationResult.Valid();
}
#region Helper Methods
private static bool IsNewRound(AfterThrowState afterThrow)
{
// New round = 9 pins were standing before this throw
// If it's a strike, all 9 were knocked down from 9 standing
if (afterThrow.IsStrike) return true;
// Otherwise check if standing pins before throw = 9
// We can infer this from: if all pins are now accounted for (knocked + still standing = 9)
// and pins knocked equals the change
var snapshot = afterThrow.ThrowPanel;
var standingBefore = snapshot.StandingPinCount() + afterThrow.PinsKnocked;
return standingBefore == 9;
}
private static int CountActivePlayers(Dictionary playerStates)
{
return playerStates.Values.Count(s => !s.IsEliminated);
}
private static int GetNextActivePlayerIndex(
Guid[] playerOrder,
int currentIndex,
Dictionary playerStates)
{
int playerCount = playerOrder.Length;
int nextIndex = (currentIndex + 1) % playerCount;
// Find next non-eliminated player
for (int i = 0; i < playerCount; i++)
{
var playerId = playerOrder[nextIndex];
if (playerStates.TryGetValue(playerId, out var state) && !state.IsEliminated)
{
return nextIndex;
}
nextIndex = (nextIndex + 1) % playerCount;
}
// All eliminated (shouldn't happen if game end is checked first)
return currentIndex;
}
private static DeathBoxGameSetup ParseSetupOptions(object? setupOptions)
{
if (setupOptions is null)
{
return new DeathBoxGameSetup();
}
if (setupOptions is DeathBoxGameSetup typedSetup)
{
return typedSetup;
}
if (setupOptions is JsonElement jsonElement)
{
try
{
return JsonSerializer.Deserialize(
jsonElement.GetRawText(),
GameModelFactory.JsonSerializerOptions) ?? new DeathBoxGameSetup();
}
catch
{
return new DeathBoxGameSetup();
}
}
return new DeathBoxGameSetup();
}
private static DeathBoxGameModel CastModel(object gameModel)
{
if (gameModel is DeathBoxGameModel model)
{
return model;
}
if (gameModel is JsonElement jsonElement)
{
return JsonSerializer.Deserialize(
jsonElement.GetRawText(),
GameModelFactory.JsonSerializerOptions)!;
}
throw new InvalidOperationException($"Expected DeathBoxGameModel but got {gameModel.GetType().Name}");
}
#endregion
}