468 lines
15 KiB
C#
468 lines
15 KiB
C#
using System.Text.Json;
|
|
using Koogle.Domain.Enums;
|
|
|
|
namespace Koogle.Application.Games.DeathBox;
|
|
|
|
/// <summary>
|
|
/// Game logic service for DeathBox (Totenkisten) game.
|
|
/// Players collect marks and are eliminated when their coffin is full.
|
|
/// Last player standing wins.
|
|
/// </summary>
|
|
public class DeathBoxGameLogicService : IGameLogicService
|
|
{
|
|
private static readonly Random Random = new();
|
|
|
|
/// <inheritdoc />
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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<Guid, DeathBoxPlayerState>(model.PlayerStates);
|
|
var eliminatedPlayers = new List<Guid>(model.EliminatedPlayers);
|
|
var triggers = new List<TriggerEvent>();
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public (bool IsGameOver, Guid? WinnerId) CheckGameEnd(object gameModel)
|
|
{
|
|
var model = CastModel(gameModel);
|
|
return (model.IsGameOver, model.WinnerId);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyDictionary<Guid, PlayerStatsSummary> 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<string, object>
|
|
{
|
|
["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
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<GameActionDescriptor> GetAvailableActions(object gameModel, Guid currentPlayerId)
|
|
{
|
|
// DeathBox has no custom actions
|
|
return [];
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public GameActionResult ExecuteAction(
|
|
object gameModel,
|
|
string actionId,
|
|
Guid currentPlayerId,
|
|
IReadOnlyDictionary<string, object>? parameters = null)
|
|
{
|
|
return GameActionResult.Failure($"Unknown action: {actionId}");
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public GameSetupValidationResult ValidateSetup(object? setupOptions)
|
|
{
|
|
if (setupOptions is null)
|
|
{
|
|
return GameSetupValidationResult.Valid();
|
|
}
|
|
|
|
var options = ParseSetupOptions(setupOptions);
|
|
var errors = new List<string>();
|
|
|
|
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<Guid, DeathBoxPlayerState> playerStates)
|
|
{
|
|
return playerStates.Values.Count(s => !s.IsEliminated);
|
|
}
|
|
|
|
private static int GetNextActivePlayerIndex(
|
|
Guid[] playerOrder,
|
|
int currentIndex,
|
|
Dictionary<Guid, DeathBoxPlayerState> 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<DeathBoxGameSetup>(
|
|
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<DeathBoxGameModel>(
|
|
jsonElement.GetRawText(),
|
|
GameModelFactory.JsonSerializerOptions)!;
|
|
}
|
|
|
|
throw new InvalidOperationException($"Expected DeathBoxGameModel but got {gameModel.GetType().Name}");
|
|
}
|
|
|
|
#endregion
|
|
}
|