Compare commits

..

2 Commits

Author SHA1 Message Date
beo3000 f1880f08a8 save setup model 2025-12-27 11:45:19 +01:00
beo3000 c3beda405c mod claude permissions 2025-12-27 11:15:59 +01:00
16 changed files with 245 additions and 84 deletions

View File

@ -10,7 +10,8 @@
"Bash(mkdir:*)",
"WebSearch",
"Bash(git merge:*)",
"Bash(findstr:*)"
"Bash(findstr:*)",
"Bash(find:*)"
]
}
}

View File

@ -24,6 +24,11 @@ public record GameStateSerializationDto
/// </summary>
public JsonElement? GameModel { get; init; }
/// <summary>
/// Game setup configuration. Persisted for game recovery.
/// </summary>
public JsonElement? Setup { get; init; }
/// <summary>
/// Stack of game snapshots for undo functionality.
/// </summary>

View File

@ -0,0 +1,52 @@
using System.Text.Json.Serialization;
using Koogle.Domain.Enums;
namespace Koogle.Application.Games;
/// <summary>
/// Interface for game setup configuration.
/// Supports JSON polymorphism for persistence.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(Shit.ShitGameSetup), "Shit")]
[JsonDerivedType(typeof(Training.TrainingGameSetup), "Training")]
public interface IGameSetupModel
{
/// <summary>
/// Throw mode for the game (Reposition or Decrease).
/// </summary>
ThrowMode ThrowMode { get; }
/// <summary>
/// Throws allowed per round.
/// </summary>
int ThrowsPerRound { get; }
/// <summary>
/// Mode for participant rotation.
/// </summary>
ParticipantsMode ParticipantsMode { get; }
/// <summary>
/// Game type identifier.
/// </summary>
string GameType { get; }
}
/// <summary>
/// Abstract base record implementing common setup properties.
/// </summary>
public abstract record GameSetupModelBase : IGameSetupModel
{
/// <inheritdoc />
public ThrowMode ThrowMode { get; init; } = ThrowMode.Reposition;
/// <inheritdoc />
public int ThrowsPerRound { get; init; } = 3;
/// <inheritdoc />
public ParticipantsMode ParticipantsMode { get; init; } = ParticipantsMode.GameLogic;
/// <inheritdoc />
public abstract string GameType { get; }
}

View File

@ -230,33 +230,33 @@ public class ShitGameLogicService : IGameLogicService
: GameSetupValidationResult.Valid();
}
private static ShitSetupOptions ParseSetupOptions(object? setupOptions)
private static ShitGameSetup ParseSetupOptions(object? setupOptions)
{
if (setupOptions is null)
{
return new ShitSetupOptions();
return new ShitGameSetup();
}
if (setupOptions is ShitSetupOptions typedOptions)
if (setupOptions is ShitGameSetup typedSetup)
{
return typedOptions;
return typedSetup;
}
if (setupOptions is JsonElement jsonElement)
{
try
{
return JsonSerializer.Deserialize<ShitSetupOptions>(
return JsonSerializer.Deserialize<ShitGameSetup>(
jsonElement.GetRawText(),
GameModelFactory.JsonSerializerOptions) ?? new ShitSetupOptions();
GameModelFactory.JsonSerializerOptions) ?? new ShitGameSetup();
}
catch
{
return new ShitSetupOptions();
return new ShitGameSetup();
}
}
return new ShitSetupOptions();
return new ShitGameSetup();
}
private static ShitGameModel CastModel(object gameModel)

View File

@ -55,25 +55,3 @@ public record ShitGameModel
/// </summary>
public bool LastThrowWasGutter { get; set; }
}
/// <summary>
/// Setup options for Scheiss-Spiel (Shit Game).
/// </summary>
public record ShitSetupOptions
{
/// <summary>
/// The "Scheiss-Zahl" (1-9, default 5). When a player knocks down exactly this many pins,
/// they must take the collected points.
/// </summary>
public int ShitNumber { get; init; } = 5;
/// <summary>
/// Starting points for each player (10-1000, default 50).
/// </summary>
public int StartNumber { get; init; } = 50;
/// <summary>
/// Mode for player rotation.
/// </summary>
public ParticipantsMode ParticipantsMode { get; init; } = ParticipantsMode.GameLogic;
}

View File

@ -0,0 +1,40 @@
using Koogle.Domain.Enums;
namespace Koogle.Application.Games.Shit;
/// <summary>
/// Setup model for Scheiss-Spiel (Shit Game).
/// </summary>
public record ShitGameSetup : GameSetupModelBase
{
/// <inheritdoc />
public override string GameType => "Shit";
/// <summary>
/// The "Scheiss-Zahl" (1-9, default 5).
/// When a player knocks down exactly this many pins, they must take the collected points.
/// </summary>
public int ShitNumber { get; init; } = 5;
/// <summary>
/// Starting points for each player (10-1000, default 50).
/// </summary>
public int StartNumber { get; init; } = 50;
/// <summary>
/// Creates a default ShitGameSetup with specified base parameters.
/// </summary>
public static ShitGameSetup Create(
ThrowMode throwMode,
int throwsPerRound,
ParticipantsMode participantsMode,
int shitNumber = 5,
int startNumber = 50) => new()
{
ThrowMode = throwMode,
ThrowsPerRound = throwsPerRound,
ParticipantsMode = participantsMode,
ShitNumber = shitNumber,
StartNumber = startNumber
};
}

View File

@ -110,18 +110,18 @@ public class TrainingGameLogicService : IGameLogicService
return GameSetupValidationResult.Valid();
}
TrainingSetupOptions options;
if (setupOptions is TrainingSetupOptions typedOptions)
TrainingGameSetup? setup;
if (setupOptions is TrainingGameSetup typedSetup)
{
options = typedOptions;
setup = typedSetup;
}
else if (setupOptions is JsonElement jsonElement)
{
try
{
options = JsonSerializer.Deserialize<TrainingSetupOptions>(
setup = JsonSerializer.Deserialize<TrainingGameSetup>(
jsonElement.GetRawText(),
GameModelFactory.JsonSerializerOptions)!;
GameModelFactory.JsonSerializerOptions);
}
catch
{
@ -133,9 +133,14 @@ public class TrainingGameLogicService : IGameLogicService
return GameSetupValidationResult.Invalid("Ungültige Setup-Optionen.");
}
if (setup is null)
{
return GameSetupValidationResult.Valid();
}
var errors = new List<string>();
if (options.ThrowsPerRound < 1 || options.ThrowsPerRound > 5)
if (setup.ThrowsPerRound < 1 || setup.ThrowsPerRound > 5)
{
errors.Add("Würfe pro Runde muss zwischen 1 und 5 liegen.");
}

View File

@ -1,5 +1,3 @@
using Koogle.Domain.Enums;
namespace Koogle.Application.Games.Training;
/// <summary>
@ -48,24 +46,3 @@ public record TrainingPlayerStats
/// </summary>
public double Average => ThrowCount > 0 ? (double)PinCount / ThrowCount : 0;
}
/// <summary>
/// Setup options for Training game.
/// </summary>
public record TrainingSetupOptions
{
/// <summary>
/// Throw mode: Reposition (in die Vollen) or Decrease (Abräumen).
/// </summary>
public ThrowMode ThrowMode { get; init; } = ThrowMode.Reposition;
/// <summary>
/// Number of throws per round (1-5, default 3).
/// </summary>
public int ThrowsPerRound { get; init; } = 3;
/// <summary>
/// Mode for player rotation.
/// </summary>
public ParticipantsMode ParticipantsMode { get; init; } = ParticipantsMode.GameLogic;
}

View File

@ -0,0 +1,25 @@
using Koogle.Domain.Enums;
namespace Koogle.Application.Games.Training;
/// <summary>
/// Setup model for Training game.
/// </summary>
public record TrainingGameSetup : GameSetupModelBase
{
/// <inheritdoc />
public override string GameType => "Training";
/// <summary>
/// Creates a default TrainingGameSetup with specified base parameters.
/// </summary>
public static TrainingGameSetup Create(
ThrowMode throwMode,
int throwsPerRound,
ParticipantsMode participantsMode) => new()
{
ThrowMode = throwMode,
ThrowsPerRound = throwsPerRound,
ParticipantsMode = participantsMode
};
}

View File

@ -1,6 +1,8 @@
@using Fluxor
@using Koogle.Application.DTOs
@using Koogle.Application.Games
@using Koogle.Application.Games.Shit
@using Koogle.Application.Games.Training
@using Koogle.Domain.Enums
@using Koogle.Domain.Interfaces
@using Koogle.Web.Store.DayState
@ -201,9 +203,13 @@
return Task.CompletedTask;
}
// Create initial game model
// Create initial game model and setup
var playerIds = _selectedParticipantIds.ToArray();
var initialModel = logicService.CreateInitialModel(playerIds, _gameSpecificSetupOptions);
// Create typed setup model based on game type
IGameSetupModel? setup = CreateSetupModel(_selectedDefinition.Name);
var initialModel = logicService.CreateInitialModel(playerIds, setup ?? _gameSpecificSetupOptions);
// Dispatch start game action
var action = new StartGameAction(
@ -213,7 +219,8 @@
ThrowMode: _throwMode,
ThrowsPerRound: _throwsPerRound,
ParticipantsMode: _participantsMode,
InitialGameModel: initialModel);
InitialGameModel: initialModel,
Setup: setup);
Dispatcher.Dispatch(action);
@ -243,4 +250,42 @@
ThrowMode.Decrease => "Abräumen",
_ => mode.ToString()
};
private IGameSetupModel? CreateSetupModel(string gameTypeName)
{
return gameTypeName switch
{
"Shit" => CreateShitSetup(),
"Training" => CreateTrainingSetup(),
_ => null
};
}
private ShitGameSetup CreateShitSetup()
{
// Extract game-specific options if available
var shitNumber = 5;
var startNumber = 50;
if (_gameSpecificSetupOptions is ShitGameSetup existingSetup)
{
shitNumber = existingSetup.ShitNumber;
startNumber = existingSetup.StartNumber;
}
return ShitGameSetup.Create(
throwMode: _throwMode,
throwsPerRound: _throwsPerRound,
participantsMode: _participantsMode,
shitNumber: shitNumber,
startNumber: startNumber);
}
private TrainingGameSetup CreateTrainingSetup()
{
return TrainingGameSetup.Create(
throwMode: _throwMode,
throwsPerRound: _throwsPerRound,
participantsMode: _participantsMode);
}
}

View File

@ -80,13 +80,13 @@
protected override async Task OnInitializedAsync()
{
if (InitialOptions is ShitSetupOptions options)
if (InitialOptions is ShitGameSetup setup)
{
_options = new ShitSetupOptionsInternal
{
ShitNumber = options.ShitNumber,
StartNumber = options.StartNumber,
ParticipantsMode = options.ParticipantsMode
ShitNumber = setup.ShitNumber,
StartNumber = setup.StartNumber,
ParticipantsMode = setup.ParticipantsMode
};
}
@ -104,19 +104,19 @@
private async Task NotifyOptionsChanged()
{
var options = new ShitSetupOptions
var setup = new ShitGameSetup
{
ShitNumber = _options.ShitNumber,
StartNumber = _options.StartNumber,
ParticipantsMode = _options.ParticipantsMode
};
await OnOptionsChanged.InvokeAsync(options);
await OnOptionsChanged.InvokeAsync(setup);
}
/// <summary>
/// Gets the current setup options.
/// Gets the current setup.
/// </summary>
public ShitSetupOptions GetOptions() => new()
public ShitGameSetup GetSetup() => new()
{
ShitNumber = _options.ShitNumber,
StartNumber = _options.StartNumber,

View File

@ -58,13 +58,13 @@
protected override void OnInitialized()
{
if (InitialOptions is TrainingSetupOptions options)
if (InitialOptions is TrainingGameSetup setup)
{
_options = new TrainingSetupOptionsInternal
{
ThrowMode = options.ThrowMode,
ThrowsPerRound = options.ThrowsPerRound,
ParticipantsMode = options.ParticipantsMode
ThrowMode = setup.ThrowMode,
ThrowsPerRound = setup.ThrowsPerRound,
ParticipantsMode = setup.ParticipantsMode
};
}
}
@ -79,19 +79,19 @@
private async Task NotifyOptionsChanged()
{
var options = new TrainingSetupOptions
var setup = new TrainingGameSetup
{
ThrowMode = _options.ThrowMode,
ThrowsPerRound = _options.ThrowsPerRound,
ParticipantsMode = _options.ParticipantsMode
};
await OnOptionsChanged.InvokeAsync(options);
await OnOptionsChanged.InvokeAsync(setup);
}
/// <summary>
/// Gets the current setup options.
/// Gets the current setup.
/// </summary>
public TrainingSetupOptions GetOptions() => new()
public TrainingGameSetup GetSetup() => new()
{
ThrowMode = _options.ThrowMode,
ThrowsPerRound = _options.ThrowsPerRound,

View File

@ -1,3 +1,4 @@
using Koogle.Application.Games;
using Koogle.Domain.Enums;
namespace Koogle.Web.Store.GameState;
@ -14,7 +15,8 @@ public record StartGameAction(
ThrowMode ThrowMode,
int ThrowsPerRound,
ParticipantsMode ParticipantsMode,
object? InitialGameModel);
object? InitialGameModel,
IGameSetupModel? Setup);
/// <summary>
/// Action dispatched when game is started successfully.
@ -24,7 +26,8 @@ public record StartGameSuccessAction(
string GameTypeName,
ThrowPanelState ThrowPanel,
ParticipantsState Participants,
object? GameModel);
object? GameModel,
IGameSetupModel? Setup);
/// <summary>
/// Action dispatched when starting game fails.
@ -60,7 +63,8 @@ public record LoadActiveGameSuccessAction(
ThrowPanelState ThrowPanel,
ParticipantsState Participants,
object? GameModel,
IReadOnlyList<GameSnapshot> UndoStack);
IReadOnlyList<GameSnapshot> UndoStack,
IGameSetupModel? Setup);
/// <summary>
/// Action dispatched when no active game exists.

View File

@ -1,6 +1,7 @@
using System.Text.Json;
using Fluxor;
using Koogle.Application.DTOs;
using Koogle.Application.Games;
using Koogle.Application.Interfaces;
using Koogle.Domain.Enums;
@ -61,6 +62,9 @@ public class GameEffects
GameModel = action.InitialGameModel != null
? JsonSerializer.SerializeToElement(action.InitialGameModel, GameStateSerializationDto.JsonOptions)
: null,
Setup = action.Setup != null
? JsonSerializer.SerializeToElement(action.Setup, GameStateSerializationDto.JsonOptions)
: null,
UndoStack = []
};
@ -94,7 +98,8 @@ public class GameEffects
action.GameTypeName,
throwPanel,
participants,
action.InitialGameModel));
action.InitialGameModel,
action.Setup));
_logger.LogInformation("Game started: {GameId}, Type: {GameType}", gameId, action.GameTypeName);
}
@ -183,13 +188,23 @@ public class GameEffects
})
.ToList();
// Deserialize setup if present (may be null for legacy games)
IGameSetupModel? setup = null;
if (stateDto.Setup.HasValue)
{
setup = JsonSerializer.Deserialize<IGameSetupModel>(
stateDto.Setup.Value.GetRawText(),
GameStateSerializationDto.JsonOptions);
}
dispatcher.Dispatch(new LoadActiveGameSuccessAction(
activeGame.Id,
activeGame.GameType,
throwPanel,
participants,
stateDto.GameModel.HasValue ? (object?)stateDto.GameModel.Value : null,
undoStack));
undoStack,
setup));
_logger.LogInformation("Active game loaded: {GameId}, Type: {GameType}", activeGame.Id, activeGame.GameType);
}
@ -353,6 +368,9 @@ public class GameEffects
GameModel = state.GameModel != null
? JsonSerializer.SerializeToElement(state.GameModel, GameStateSerializationDto.JsonOptions)
: null,
Setup = state.Setup != null
? JsonSerializer.SerializeToElement(state.Setup, GameStateSerializationDto.JsonOptions)
: null,
UndoStack = state.UndoStack.Select(s => new GameSnapshotDto
{
ThrowPanel = MapThrowPanelToDto(s.ThrowPanel),

View File

@ -35,6 +35,7 @@ public static class GameReducers
ThrowPanel = action.ThrowPanel,
Participants = action.Participants,
GameModel = action.GameModel,
Setup = action.Setup,
UndoStack = [],
IsLoading = false,
Error = null
@ -75,6 +76,7 @@ public static class GameReducers
ThrowPanel = ThrowPanelState.Initial,
Participants = ParticipantsState.Initial,
GameModel = null,
Setup = null,
UndoStack = [],
CompletedGames = [.. state.CompletedGames, action.Summary],
IsLoading = false
@ -116,6 +118,7 @@ public static class GameReducers
ThrowPanel = action.ThrowPanel,
Participants = action.Participants,
GameModel = action.GameModel,
Setup = action.Setup,
UndoStack = action.UndoStack.ToImmutableList(),
IsLoading = false
};
@ -387,6 +390,7 @@ public static class GameReducers
ThrowPanel = ThrowPanelState.Initial,
Participants = ParticipantsState.Initial,
GameModel = null,
Setup = null,
UndoStack = [],
CompletedGames = [.. state.CompletedGames, action.Summary]
};

View File

@ -1,5 +1,6 @@
using System.Collections.Immutable;
using Fluxor;
using Koogle.Application.Games;
using Koogle.Domain.Enums;
namespace Koogle.Web.Store.GameState;
@ -45,6 +46,11 @@ public record GameState
/// </summary>
public object? GameModel { get; init; }
/// <summary>
/// Game setup configuration (persisted for recovery).
/// </summary>
public IGameSetupModel? Setup { get; init; }
/// <summary>
/// Stack of game snapshots for undo functionality (unlimited).
/// </summary>
@ -92,6 +98,7 @@ public record GameState
ThrowPanel = ThrowPanelState.Initial,
Participants = ParticipantsState.Initial,
GameModel = null,
Setup = null,
UndoStack = [],
CompletedGames = [],
IsLoading = false,