fox hunt part1

This commit is contained in:
beo3000 2026-01-04 21:24:30 +01:00
parent 9356dab062
commit 29fa9c5e84
9 changed files with 715 additions and 0 deletions

View File

@ -0,0 +1,32 @@
using Koogle.Application.Games.Shit;
using Koogle.Domain.Interfaces;
namespace Koogle.Application.Games.FoxHunt
{
/// <summary>
/// Game definition for Fuchsjagd-Spiel (Fox Hunt) game type.
/// One Player starts as fox, and all others try to hunt him.
/// </summary>
public class FoxHuntGameDefinition : IGameDefinition
{
/// <inheritdoc />
public string Name => "FoxHunt";
/// <inheritdoc />
public string DisplayName => "Fuchsjagd";
/// <inheritdoc />
public Type SetupComponentType => Type.GetType(
"Koogle.Web.Components.Game.FoxHunt.FoxSetup, Koogle.Web")!;
/// <inheritdoc />
public Type BoardComponentType => Type.GetType(
"Koogle.Web.Components.Game.FoxHunt.FoxBoard, Koogle.Web")!;
/// <inheritdoc />
public Type GameLogicServiceType => typeof(FoxHuntGameLogicService);
/// <inheritdoc />
public Type GameModelType => typeof(FoxHuntGameModel);
}
}

View File

@ -0,0 +1,272 @@
using Koogle.Application.Games.DeathBox;
using Koogle.Application.Interfaces;
using Koogle.Domain.Enums;
using Microsoft.Extensions.DependencyInjection;
using Org.BouncyCastle.Cms;
using System;
using System.Text.Json;
namespace Koogle.Application.Games.FoxHunt
{
/// <summary>
/// Game logic service for Fuchsjagd-Spiel (Fox Hunt) game type.
/// One Player starts as fox, and all others try to hunt him.
/// </summary>
public class FoxHuntGameLogicService : IGameLogicService
{
private readonly IServiceProvider _serviceProvider;
public FoxHuntGameLogicService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public object CreateInitialModel(Guid[] playerIds, object? setupOptions)
{
var options = ParseSetupOptions(setupOptions);
var playerOrder = playerIds.ToArray();
var playerStates = playerIds.ToDictionary(
id => id,
_ => new FoxHuntPlayerState());
return new FoxHuntGameModel
{
FoxIndex = 0, // aktueller Fuchs (Index in PlayerOrder)
NonFoxIndex = 0, // Index für Nicht-Fuchs-Spieler
FoxTurnsRemaining = 2, // erste 2 Fuchs-Züge
FoxTurn = false,
LeadingThrows = options.LeadingThrows,
PlayerStates = playerStates,
PlayerOrder = playerOrder,
//CurrentPlayerIndex = 0,
//FoxLeadingThrowCount = 0,
//CurrentFoxIndex = 0,
WinnerId = null,
IsGameOver = false,
};
}
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 actualName = GetPlayerName(playerId).Result;
var playerStates = new Dictionary<Guid, FoxHuntPlayerState>(model.PlayerStates);
//var eliminatedPlayers = new List<Guid>(model.EliminatedPlayers);
var triggers = new List<TriggerEvent>();
var foxId = model.PlayerOrder[model.FoxIndex];
var isFoxThrow = playerId == foxId;
// 5. Check GAME END
bool isGameOver = false;
Guid? winnerId = null;
var nextPlayerId = GetNextId(model);
//var nextName = GetPlayerName(nextPlayerId).Result;
// Update model
model = model with
{
FoxIndex = model.FoxIndex,
FoxTurn = model.FoxTurn,
FoxTurnsRemaining = model.FoxTurnsRemaining,
NonFoxIndex = model.NonFoxIndex
};
var result = new ThrowResult
{
PointsScored = pinsKnocked,
ShouldRotatePlayer = true,
IsGameOver = isGameOver,
WinnerId = winnerId,
Triggers = triggers,
Overrides = isGameOver ? null : new GameLogicOverrides
{
NextPlayerId = nextPlayerId
}
};
return (model, result);
}
/// <summary>
/// algorithm:
/// A, A, B, A, C, A, D,
/// B, B, A, B, C, B, D,
/// C, C, A, C, B, C, D,
/// D, D, A, D, B, D, C
/// </summary>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
private Guid GetNextId(FoxHuntGameModel model)
{
// Abbruch: alle waren einmal Fuchs
if (model.FoxIndex >= model.PlayerOrder.Length)
throw new InvalidOperationException("Alle Spieler waren bereits Fuchs.");
Guid foxId = model.PlayerOrder[model.FoxIndex];
// 1⃣ Fuchs ist 2x hintereinander dran
if (model.FoxTurnsRemaining > 0)
{
model.FoxTurnsRemaining--;
model.FoxTurn = false; // danach abwechselnd
return foxId;
}
// 2⃣ Abwechselnd Nicht-Fuchs → Fuchs
if (!model.FoxTurn)
{
// nächsten Nicht-Fuchs suchen
while (model.NonFoxIndex == model.FoxIndex)
model.NonFoxIndex++;
if (model.NonFoxIndex < model.PlayerOrder.Length)
{
Guid next = model.PlayerOrder[model.NonFoxIndex];
model.NonFoxIndex++;
model.FoxTurn = true;
return next;
}
else
{
// Alle Nicht-Füchse durch → neuer Fuchs
model.FoxIndex++;
model.NonFoxIndex = 0;
model.FoxTurnsRemaining = 2;
model.FoxTurn = true;
return GetNextId(model);
}
}
// 3⃣ Fuchs-Zug im Wechsel
model.FoxTurn = false;
return foxId;
}
private static int GetNextActivePlayerIndex(
Guid[] playerOrder,
int currentIndex,
Dictionary<Guid, FoxHuntPlayerState> playerStates)
{
return 0;
}
private async Task<string> GetPlayerName(Guid playerId)
{
using (var scope = _serviceProvider.CreateScope())
{
var _personService = scope.ServiceProvider.GetRequiredService<IPersonService>();
var persons = await _personService.GetAllAsync();
var person = persons.FirstOrDefault(p => p.Id == playerId);
return person?.Name ?? "Unbekannt";
}
}
public (bool IsGameOver, Guid? WinnerId) CheckGameEnd(object gameModel)
{
throw new NotImplementedException();
}
public IReadOnlyDictionary<Guid, PlayerStatsSummary> GetPlayerStats(object gameModel)
{
throw new NotImplementedException();
}
public GameSetupValidationResult ValidateSetup(object? setupOptions)
{
if (setupOptions is null)
{
return GameSetupValidationResult.Valid();
}
var options = ParseSetupOptions(setupOptions);
var errors = new List<string>();
if (options.LeadingThrows < 2 || options.LeadingThrows > 4)
{
errors.Add("Vorsprung muss zwischen 6 und 12 liegen.");
}
//TODO: validate player-count, at lease 2 players needed
return errors.Count > 0
? GameSetupValidationResult.Invalid(errors.ToArray())
: GameSetupValidationResult.Valid();
}
public IReadOnlyList<GameActionDescriptor> GetAvailableActions(object gameModel, Guid currentPlayerId)
{
// FoxHunt has no custom actions
return [];
}
public GameActionResult ExecuteAction(object gameModel, string actionId, Guid currentPlayerId,
IReadOnlyDictionary<string, object>? parameters = null)
{
return GameActionResult.Failure($"Unknown action: {actionId}");
}
private static FoxHuntGameSetup ParseSetupOptions(object? setupOptions)
{
if (setupOptions is null)
{
return new FoxHuntGameSetup();
}
if (setupOptions is FoxHuntGameSetup typedSetup)
{
return typedSetup;
}
if (setupOptions is JsonElement jsonElement)
{
try
{
return JsonSerializer.Deserialize<FoxHuntGameSetup>(
jsonElement.GetRawText(),
GameModelFactory.JsonSerializerOptions) ?? new FoxHuntGameSetup();
}
catch
{
return new FoxHuntGameSetup();
}
}
return new FoxHuntGameSetup();
}
private static FoxHuntGameModel CastModel(object gameModel)
{
if (gameModel is FoxHuntGameModel model)
{
return model;
}
if (gameModel is JsonElement jsonElement)
{
return JsonSerializer.Deserialize<FoxHuntGameModel>(
jsonElement.GetRawText(),
GameModelFactory.JsonSerializerOptions)!;
}
throw new InvalidOperationException($"Expected FoxHuntGameModel but got {gameModel.GetType().Name}");
}
}
}

View File

@ -0,0 +1,38 @@
namespace Koogle.Application.Games.FoxHunt
{
/// <summary>
/// Game model for Fuchsjagd-Spiel (Fox Hunt) game type.
/// One Player starts as fox, and all others try to hunt him.
/// </summary>
public record FoxHuntGameModel
{
/// <summary>
/// ID of the winner (first player to reach 0 points).
/// </summary>
public Guid? WinnerId { get; set; }
public bool IsGameOver { get; set; }
//public int LeadingThrows { get; set; }
//public int FoxLeadingThrowCount { get; set; }
public Dictionary<Guid, FoxHuntPlayerState> PlayerStates { get; set; }
public Guid[] PlayerOrder { get; set; }
public int LeadingThrows { get; set; } // copy from setup model
//public int CurrentPlayerIndex { get; set; }
//public int CurrentFoxIndex { get; set; }
public int FoxIndex = 0; // aktueller Fuchs (Index in PlayerOrder)
public int NonFoxIndex = 0; // Index für Nicht-Fuchs-Spieler
public int FoxTurnsRemaining = 2; // erste 2 Fuchs-Züge
public bool FoxTurn = true; // wer ist dran
}
public record FoxHuntPlayerState
{
public int PinCount { get; set; }
}
}

View File

@ -0,0 +1,34 @@
using Koogle.Domain.Enums;
namespace Koogle.Application.Games.FoxHunt
{
/// <summary>
/// Setup model for Fuchsjagd-Spiel (Fox Hunt) game type.
/// One Player starts as fox, and all others try to hunt him.
/// </summary>
public record FoxHuntGameSetup : GameSetupModelBase
{
public override string GameType => "FoxHunt";
/// <summary>
/// Leading throw the fox do, before the hunt begins
/// </summary>
public int LeadingThrows { get; init; } = 2;
/// <summary>
/// Creates a default ShitGameSetup with specified base parameters.
/// </summary>
public static FoxHuntGameSetup Create(
ThrowMode throwMode,
int throwsPerRound,
ParticipantsMode participantsMode,
int leadingThrows = 2) => new()
{
ThrowMode = throwMode,
ThrowsPerRound = throwsPerRound,
ParticipantsMode = participantsMode,
LeadingThrows = leadingThrows
};
}
}

View File

@ -11,6 +11,7 @@ namespace Koogle.Application.Games;
[JsonDerivedType(typeof(Shit.ShitGameSetup), "Shit")]
[JsonDerivedType(typeof(Training.TrainingGameSetup), "Training")]
[JsonDerivedType(typeof(DeathBox.DeathBoxGameSetup), "DeathBox")]
[JsonDerivedType(typeof(FoxHunt.FoxHuntGameSetup), "FoxHunt")]
public interface IGameSetupModel
{
/// <summary>

View File

@ -0,0 +1,213 @@
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
@using System.Text.Json
@using Koogle.Application.Games.FoxHunt
@using Koogle.Web.Store.DayState
@using Koogle.Web.Store.GameState
@using Koogle.Application.Games;
@implements IDisposable
@inject IState<GameState> GameState
@inject IState<DayState> DayState
<pre>
@_debug
</pre>
<MudPaper Class="pa-4">
@if (_model == null)
{
<MudAlert Severity="Severity.Info">
Spiel noch nicht gestartet.
</MudAlert>
}
else
{
@* Game info header *@
<MudPaper Class="pa-3 mb-4" Elevation="0" Style="background-color: var(--mud-palette-background-grey);">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body1">
<strong>Vorsprung für den Fuchs:</strong>
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">
@_model.LeadingThrows Vorsprung
</MudChip>
</MudText>
<MudText Typo="Typo.body1">
<strong>Füchse übrig:</strong>
<MudChip T="string" Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">
0
</MudChip>
</MudText>
</MudStack>
</MudPaper>
@* Last throw info *@
@* @if (_model.LastThrow != null)
{
<MudAlert Severity="@GetLastThrowSeverity()" Class="mb-4" Dense="true">
@GetLastThrowMessage()
</MudAlert>
} *@
@* Winner announcement *@
@if (_model.IsGameOver && _model.WinnerId.HasValue)
{
var winnerName = GetPlayerName(_model.WinnerId.Value);
<MudAlert Severity="Severity.Success" Class="mb-4">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.EmojiEvents" />
<MudText Typo="Typo.h6">@winnerName hat überlebt!</MudText>
</MudStack>
</MudAlert>
}
@* Players table *@
<MudTable Items="@_playerStats"
Dense="true"
Hover="true"
Striped="true"
Bordered="true"
Class="mb-4">
<HeaderContent>
<MudTh>Spieler</MudTh>
<MudTh>Status</MudTh>
<MudTh Style="text-align: center">Xs</MudTh>
<MudTh Style="text-align: center">Eier</MudTh>
<MudTh Style="text-align: center">Status</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
@if (context.IsCurrentPlayer && !_model.IsGameOver)
{
<MudBadge Color="Color.Primary" Dot="true" Overlap="false"
Icon=@Icons.Material.Filled.ArrowCircleDown
Origin="Origin.TopLeft">
<MudText Typo="Typo.body1" Style="font-weight: 600">
@context.PlayerName
</MudText>
</MudBadge>
}
else
{
<MudText Typo="Typo.body1" Style="@(context.IsEliminated ? "text-decoration: line-through; color: var(--mud-palette-text-disabled);" : "")">
@context.PlayerName
</MudText>
}
</MudTd>
<MudTd Style="text-align: center">
@if (context.IsWinner)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled"
Icon="@Icons.Material.Filled.EmojiEvents">
SIEGER
</MudChip>
}
else if (context.IsEliminated)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Outlined">
☠️ Platz @context.FoxSurvivalOrder
</MudChip>
}
else if (context.IsCurrentPlayer)
{
<MudChip T="string" Size="Size.Small" Color="Color.Primary" Variant="Variant.Outlined">
Am Zug
</MudChip>
}
</MudTd>
</RowTemplate>
</MudTable>
@* Info footer *@
@if (!_model.IsGameOver)
{
<MudPaper Class="pa-3" Elevation="0" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Überlebe als als Fuchs, mit dem größten Vorsprung!
</MudText>
</MudPaper>
}
}
</MudPaper>
@code {
private FoxHuntGameModel? _model;
private List<PlayerStatsRow> _playerStats = [];
private string _debug;
protected override void OnInitialized()
{
base.OnInitialized();
GameState.StateChanged += OnGameStateChanged;
UpdateStats();
}
private void OnGameStateChanged(object? sender, EventArgs e)
{
UpdateStats();
InvokeAsync(StateHasChanged);
}
private void UpdateStats()
{
_playerStats.Clear();
_model = null;
// _activePlayerCount = 0;
var gameState = GameState.Value;
if (gameState.GameModel is FoxHuntGameModel model)
{
_model = model;
}
else if (gameState.GameModel is JsonElement jsonElement)
{
try
{
_model = JsonSerializer.Deserialize<FoxHuntGameModel>(
jsonElement.GetRawText(),
GameModelFactory.JsonSerializerOptions);
}
catch
{
return;
}
}
_debug = JsonSerializer.Serialize(_model, new JsonSerializerOptions { WriteIndented = true });
InvokeAsync(StateHasChanged);
if (_model?.PlayerStates == null || _model.PlayerOrder == null)
{
return;
}
}
public void Dispose()
{
GameState.StateChanged -= OnGameStateChanged;
}
private string GetPlayerName(Guid playerId)
{
var person = DayState.Value.AvailablePersons.FirstOrDefault(p => p.Id == playerId);
return person?.Name ?? "Unbekannt";
}
private record PlayerStatsRow
{
public Guid PlayerId { get; init; }
public string PlayerName { get; init; } = "";
public string Status { get; init; } = "";
public bool IsWinner { get; init; }
public bool IsCurrentPlayer { get; init; }
public bool IsEliminated { get; init; }
public int FoxSurvivalOrder { get; init; }
}
}

View File

@ -0,0 +1,121 @@
@inject ITriggerService TriggerService
@using Koogle.Application.Games
@using Koogle.Application.Games.FoxHunt
@using Koogle.Application.Interfaces
@using Koogle.Domain.Enums
@implements Koogle.Application.Interfaces.IGameSetupControl
<MudPaper Class="pa-4">
<MudText Typo="Typo.h6" Class="mb-4">Fuchsjagd Einstellungen</MudText>
<MudStack Spacing="4">
<MudNumericField T="int"
@bind-Value="_options.LeadingThrows"
Label="Würfe-Vorsprung"
Variant="Variant.Outlined"
Min="2"
Max="4"
HelperText="Anzahl Würfe, die der Fuchs an Vorspring bekommt (2-4)" />
</MudStack>
@if (!_hasExpensePointTrigger)
{
<MudAlert Severity="Severity.Warning" Class="mt-4">
<MudText Typo="Typo.body2">
<strong>Hinweis:</strong> Es ist kein "Strafpunkt"-Trigger konfiguriert.
Die Verlierer-Strafen werden nicht automatisch berechnet.
Gehe zu Stammdaten → Trigger, um einen ExpensePoint-Trigger einzurichten.
</MudText>
</MudAlert>
}
<MudDivider Class="my-4" />
<MudText Typo="Typo.body2" Color="Color.Secondary">
<strong>Spielregeln:</strong>
<ul style="margin: 8px 0 0 16px; padding: 0;">
<li>Jeder Spieler reihum ist einmal der Fuchs</li>
<li>Der Fuchs startet mit 'Anzahl-Würfe-Vorsprung' in die Vollen</li>
<li>Er sammelt möglichst viele Kegel und erarbeitet sich sich einen Vorsprung</li>
<li>Anschließend hat jeder weitere Spieler einen Wurf, abwechselnd mit dem Fuchs</li>
<li>Ziel der anderen Spieler ist es, den Fuchs zu fangen!</li>
<li>Ziel des Fuchses ist es, am Ende der Runde mindestens einen Punkt Vorsprung zu haben</li>
<li>Es gewinnt der Fuchs mit dem größten Vorsprung</li>
</ul>
</MudText>
</MudPaper>
@code {
/// <summary>
/// Callback when setup options change.
/// </summary>
[Parameter]
public EventCallback<object> OnOptionsChanged { get; set; }
/// <summary>
/// Initial setup options.
/// </summary>
[Parameter]
public object? InitialOptions { get; set; }
private bool _hasExpensePointTrigger = true;
private FoxSetupOptionsInternal _options = new();
// Internal mutable class for two-way binding
private class FoxSetupOptionsInternal
{
public int LeadingThrows { get; set; } = 2;
public ParticipantsMode ParticipantsMode { get; set; } = ParticipantsMode.GameLogic;
}
protected override async Task OnInitializedAsync()
{
if (InitialOptions is FoxHuntGameSetup setup)
{
_options = new FoxSetupOptionsInternal
{
LeadingThrows = setup.LeadingThrows,
ParticipantsMode = setup.ParticipantsMode
};
}
// Check if ExpensePoint trigger is configured
_hasExpensePointTrigger = await TriggerService.HasExpensesForTriggerAsync(ExpenseTriggerType.ExpensePoint);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await NotifyOptionsChanged();
}
}
private async Task NotifyOptionsChanged()
{
var setup = new FoxHuntGameSetup
{
LeadingThrows = _options.LeadingThrows,
ParticipantsMode = _options.ParticipantsMode
};
await OnOptionsChanged.InvokeAsync(setup);
}
public IGameSetupModel GameSetupModel => CreateFoxSetup();
/// <summary>
/// Creates a default FoxHuntSetup with specified base parameters.
/// </summary>
private FoxHuntGameSetup CreateFoxSetup()
{
return FoxHuntGameSetup.Create(
throwMode: ThrowMode.Reposition,
throwsPerRound: 1,
participantsMode: ParticipantsMode.GameLogic,
leadingThrows: _options.LeadingThrows);
}
}

View File

@ -92,6 +92,8 @@
{
"Training" => Icons.Material.Filled.FitnessCenter,
"Shit" => Icons.Material.Filled.Casino,
"DeathBox" => Icons.Material.Filled.Church,
"FoxHunt" => Icons.Material.Filled.DirectionsRun,
_ => Icons.Material.Filled.SportsScore
};
}

View File

@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using MudBlazor.Services;
using System.Reflection;
using Koogle.Application.Games.FoxHunt;
var builder = WebApplication.CreateBuilder(args);
@ -32,6 +33,7 @@ builder.Services.AddApplication();
builder.Services.AddGameType<TrainingGameDefinition, TrainingGameLogicService>();
builder.Services.AddGameType<ShitGameDefinition, ShitGameLogicService>();
builder.Services.AddGameType<DeathBoxGameDefinition, DeathBoxGameLogicService>();
builder.Services.AddGameType<FoxHuntGameDefinition, FoxHuntGameLogicService>();
// SignalR for real-time game updates
builder.Services.AddSignalR();