From caeb1a8e8b0c3222f8366a8e20b14e2bbcd4f31d Mon Sep 17 00:00:00 2001 From: beo3000 Date: Mon, 5 Jan 2026 21:07:22 +0100 Subject: [PATCH] added Team-Support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Beliebig viele Teams - Editierbare Team-Namen - "Zufällig verteilen" Button - Warnung bei ungleichen Team-Größen - Validierung bei Required-Modus --- .../Games/DeathBox/DeathBoxGameDefinition.cs | 10 + .../Games/FoxHunt/FoxHuntGameDefinition.cs | 10 + src/Koogle.Application/Games/GameTeam.cs | 17 + .../Games/IGameSetupModel.cs | 9 + .../Games/Shit/ShitGameDefinition.cs | 10 + .../Games/Training/TrainingGameDefinition.cs | 10 + src/Koogle.Domain/Enums/TeamMode.cs | 22 ++ .../Interfaces/IGameDefinition.cs | 17 + .../Components/Game/GameSetupDialog.razor | 40 ++- .../Game/TeamAssignmentSelector.razor | 303 ++++++++++++++++++ 10 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 src/Koogle.Application/Games/GameTeam.cs create mode 100644 src/Koogle.Domain/Enums/TeamMode.cs create mode 100644 src/Koogle.Web/Components/Game/TeamAssignmentSelector.razor diff --git a/src/Koogle.Application/Games/DeathBox/DeathBoxGameDefinition.cs b/src/Koogle.Application/Games/DeathBox/DeathBoxGameDefinition.cs index 52be374..2624ec4 100644 --- a/src/Koogle.Application/Games/DeathBox/DeathBoxGameDefinition.cs +++ b/src/Koogle.Application/Games/DeathBox/DeathBoxGameDefinition.cs @@ -1,3 +1,4 @@ +using Koogle.Domain.Enums; using Koogle.Domain.Interfaces; namespace Koogle.Application.Games.DeathBox; @@ -29,4 +30,13 @@ public class DeathBoxGameDefinition : IGameDefinition /// public Type GameModelType => typeof(DeathBoxGameModel); + + /// + public TeamMode TeamMode => TeamMode.None; + + /// + public int MinTeams => 2; + + /// + public int? MaxTeams => null; } diff --git a/src/Koogle.Application/Games/FoxHunt/FoxHuntGameDefinition.cs b/src/Koogle.Application/Games/FoxHunt/FoxHuntGameDefinition.cs index 5f98eac..72b7b69 100644 --- a/src/Koogle.Application/Games/FoxHunt/FoxHuntGameDefinition.cs +++ b/src/Koogle.Application/Games/FoxHunt/FoxHuntGameDefinition.cs @@ -1,4 +1,5 @@ using Koogle.Application.Games.Shit; +using Koogle.Domain.Enums; using Koogle.Domain.Interfaces; namespace Koogle.Application.Games.FoxHunt @@ -28,5 +29,14 @@ namespace Koogle.Application.Games.FoxHunt /// public Type GameModelType => typeof(FoxHuntGameModel); + + /// + public TeamMode TeamMode => TeamMode.None; + + /// + public int MinTeams => 2; + + /// + public int? MaxTeams => null; } } diff --git a/src/Koogle.Application/Games/GameTeam.cs b/src/Koogle.Application/Games/GameTeam.cs new file mode 100644 index 0000000..5c31d20 --- /dev/null +++ b/src/Koogle.Application/Games/GameTeam.cs @@ -0,0 +1,17 @@ +namespace Koogle.Application.Games; + +/// +/// Represents a team in a game with assigned players. +/// +public record GameTeam +{ + /// + /// Team name (editable by user). + /// + public string Name { get; init; } = ""; + + /// + /// Player IDs assigned to this team. + /// + public IReadOnlyList PlayerIds { get; init; } = []; +} diff --git a/src/Koogle.Application/Games/IGameSetupModel.cs b/src/Koogle.Application/Games/IGameSetupModel.cs index 5d9a8f3..e2750e7 100644 --- a/src/Koogle.Application/Games/IGameSetupModel.cs +++ b/src/Koogle.Application/Games/IGameSetupModel.cs @@ -33,6 +33,12 @@ public interface IGameSetupModel /// Game type identifier. /// string GameType { get; } + + /// + /// Team assignments for team-based games. + /// Null when game has no teams. + /// + IReadOnlyList? Teams { get; } } /// @@ -51,4 +57,7 @@ public abstract record GameSetupModelBase : IGameSetupModel /// public abstract string GameType { get; } + + /// + public IReadOnlyList? Teams { get; init; } } diff --git a/src/Koogle.Application/Games/Shit/ShitGameDefinition.cs b/src/Koogle.Application/Games/Shit/ShitGameDefinition.cs index f4cab24..e812d68 100644 --- a/src/Koogle.Application/Games/Shit/ShitGameDefinition.cs +++ b/src/Koogle.Application/Games/Shit/ShitGameDefinition.cs @@ -1,3 +1,4 @@ +using Koogle.Domain.Enums; using Koogle.Domain.Interfaces; namespace Koogle.Application.Games.Shit; @@ -28,4 +29,13 @@ public class ShitGameDefinition : IGameDefinition /// public Type GameModelType => typeof(ShitGameModel); + + /// + public TeamMode TeamMode => TeamMode.None; + + /// + public int MinTeams => 2; + + /// + public int? MaxTeams => null; } diff --git a/src/Koogle.Application/Games/Training/TrainingGameDefinition.cs b/src/Koogle.Application/Games/Training/TrainingGameDefinition.cs index e56a622..3d1ad7b 100644 --- a/src/Koogle.Application/Games/Training/TrainingGameDefinition.cs +++ b/src/Koogle.Application/Games/Training/TrainingGameDefinition.cs @@ -1,3 +1,4 @@ +using Koogle.Domain.Enums; using Koogle.Domain.Interfaces; namespace Koogle.Application.Games.Training; @@ -26,4 +27,13 @@ public class TrainingGameDefinition : IGameDefinition /// public Type GameModelType => typeof(TrainingGameModel); + + /// + public TeamMode TeamMode => TeamMode.Optional; + + /// + public int MinTeams => 2; + + /// + public int? MaxTeams => null; } diff --git a/src/Koogle.Domain/Enums/TeamMode.cs b/src/Koogle.Domain/Enums/TeamMode.cs new file mode 100644 index 0000000..3a1840c --- /dev/null +++ b/src/Koogle.Domain/Enums/TeamMode.cs @@ -0,0 +1,22 @@ +namespace Koogle.Domain.Enums; + +/// +/// Defines the team mode for a game type. +/// +public enum TeamMode +{ + /// + /// No teams - individual play only. + /// + None, + + /// + /// Teams are optional - can play with or without teams. + /// + Optional, + + /// + /// Teams are required - game cannot start without team assignment. + /// + Required +} diff --git a/src/Koogle.Domain/Interfaces/IGameDefinition.cs b/src/Koogle.Domain/Interfaces/IGameDefinition.cs index 1976806..fee2222 100644 --- a/src/Koogle.Domain/Interfaces/IGameDefinition.cs +++ b/src/Koogle.Domain/Interfaces/IGameDefinition.cs @@ -1,3 +1,5 @@ +using Koogle.Domain.Enums; + namespace Koogle.Domain.Interfaces; /// @@ -34,4 +36,19 @@ public interface IGameDefinition /// Type of the game-specific model class. /// Type GameModelType { get; } + + /// + /// Team mode for this game type. + /// + TeamMode TeamMode { get; } + + /// + /// Minimum number of teams required (when TeamMode != None). + /// + int MinTeams { get; } + + /// + /// Maximum number of teams allowed (null = unlimited). + /// + int? MaxTeams { get; } } diff --git a/src/Koogle.Web/Components/Game/GameSetupDialog.razor b/src/Koogle.Web/Components/Game/GameSetupDialog.razor index f3d15d1..1b3f478 100644 --- a/src/Koogle.Web/Components/Game/GameSetupDialog.razor +++ b/src/Koogle.Web/Components/Game/GameSetupDialog.razor @@ -10,6 +10,8 @@ @using Koogle.Web.Store.GameState @using MudBlazor +@* Team support *@ + @inject IState DayState @inject IDispatcher Dispatcher @inject GameDefinitionRegistry GameRegistry @@ -32,7 +34,20 @@ @bind-SelectedParticipantIds="_selectedParticipantIds" MinimumParticipants="1" /> - + + @* Step 2b: Team Assignment (only if game supports teams) *@ + @if (_selectedDefinition?.TeamMode != TeamMode.None) + { + + + + } + @* Step 3: Game-specific Setup *@ Teilnehmer: @_selectedParticipantIds.Count Spieler + @if (_teams.Count > 0) + { + + Teams: @_teams.Count (@string.Join(", ", _teams.Select(t => $"{t.Name}: {t.PlayerIds.Count}"))) + + } Modus: @GetThrowModeLabel() @@ -129,6 +150,7 @@ private IGameDefinition? _selectedDefinition; private IReadOnlyList _availableParticipants = []; private IReadOnlyList _selectedParticipantIds = []; + private IReadOnlyList _teams = []; // private object? _gameSpecificSetupOptions; // private ThrowMode _throwMode = ThrowMode.Reposition; // private int _throwsPerRound = 3; @@ -153,6 +175,7 @@ _selectedDefinition = definition; // _gameSpecificSetupOptions = null; _validationError = null; + _teams = []; // Reset teams when game type changes if (definition != null) { @@ -160,6 +183,9 @@ } } + private IReadOnlyList GetSelectedParticipants() => + _availableParticipants.Where(p => _selectedParticipantIds.Contains(p.PersonId)).ToList(); + private Dictionary GetSetupParameters() { return new Dictionary @@ -183,6 +209,18 @@ if (_selectedParticipantIds.Count == 0) return false; + // Validate teams if required + if (_selectedDefinition.TeamMode == TeamMode.Required) + { + if (_teams.Count < _selectedDefinition.MinTeams) + return false; + + // Check all players are assigned + var assignedPlayers = _teams.SelectMany(t => t.PlayerIds).ToHashSet(); + if (!_selectedParticipantIds.All(id => assignedPlayers.Contains(id))) + return false; + } + return true; } diff --git a/src/Koogle.Web/Components/Game/TeamAssignmentSelector.razor b/src/Koogle.Web/Components/Game/TeamAssignmentSelector.razor new file mode 100644 index 0000000..454a7bc --- /dev/null +++ b/src/Koogle.Web/Components/Game/TeamAssignmentSelector.razor @@ -0,0 +1,303 @@ +@using Koogle.Application.DTOs +@using Koogle.Application.Games +@using Koogle.Domain.Enums +@using MudBlazor + + + + Team-Einteilung + + + Zufällig + + + Team + + + + + @if (_teams.Count == 0) + { + + Füge Teams hinzu, um Spieler zuzuweisen. + + } + else + { + @* Unbalanced teams warning *@ + @if (HasUnbalancedTeams()) + { + + Teams haben unterschiedliche Größen. + + } + + + @for (int i = 0; i < _teams.Count; i++) + { + var teamIndex = i; + var team = _teams[teamIndex]; + + + + + + + + (@team.PlayerIds.Count) + + + + + + + + + + @foreach (var participant in _availableParticipants) + { + var isInThisTeam = team.PlayerIds.Contains(participant.PersonId); + var isInAnyTeam = IsPlayerInAnyTeam(participant.PersonId); + + + + + @participant.PersonName + + + + } + + + + + } + + } + + @* Summary *@ + @if (_teams.Count > 0) + { + var unassigned = GetUnassignedPlayers().Count; + + @if (unassigned > 0) + { + @($"{unassigned} Spieler nicht zugewiesen") + } + else + { + @("Alle Spieler zugewiesen") + } + + } + + +@code { + /// + /// List of available participants from the day. + /// + [Parameter] + public IReadOnlyList AvailableParticipants { get; set; } = []; + + /// + /// Current team assignments. + /// + [Parameter] + public IReadOnlyList Teams { get; set; } = []; + + /// + /// Callback when teams change. + /// + [Parameter] + public EventCallback> TeamsChanged { get; set; } + + /// + /// Minimum number of teams required. + /// + [Parameter] + public int MinTeams { get; set; } = 2; + + /// + /// Maximum number of teams allowed (null = unlimited). + /// + [Parameter] + public int? MaxTeams { get; set; } + + private List _availableParticipants = []; + private List _teams = []; + + private class MutableTeam + { + public string Name { get; set; } = ""; + public HashSet PlayerIds { get; set; } = new(); + } + + protected override void OnParametersSet() + { + _availableParticipants = AvailableParticipants.ToList(); + + // Initialize teams from parameter + if (Teams.Count > 0 && _teams.Count == 0) + { + _teams = Teams.Select(t => new MutableTeam + { + Name = t.Name, + PlayerIds = t.PlayerIds.ToHashSet() + }).ToList(); + } + + // Auto-create minimum teams if none exist + if (_teams.Count == 0 && MinTeams > 0) + { + for (int i = 0; i < MinTeams; i++) + { + _teams.Add(new MutableTeam { Name = $"Team {i + 1}" }); + } + } + } + + private async Task AddTeam() + { + if (MaxTeams.HasValue && _teams.Count >= MaxTeams.Value) + return; + + _teams.Add(new MutableTeam { Name = $"Team {_teams.Count + 1}" }); + await NotifyTeamsChanged(); + } + + private async Task RemoveTeam(int index) + { + if (_teams.Count <= MinTeams) + return; + + _teams.RemoveAt(index); + await NotifyTeamsChanged(); + } + + private async Task UpdateTeamName(int index, string name) + { + _teams[index].Name = name; + await NotifyTeamsChanged(); + } + + private async Task TogglePlayerInTeam(int teamIndex, Guid playerId) + { + var team = _teams[teamIndex]; + + // Remove from other teams first + foreach (var t in _teams) + { + if (t != team) + { + t.PlayerIds.Remove(playerId); + } + } + + // Toggle in this team + if (team.PlayerIds.Contains(playerId)) + { + team.PlayerIds.Remove(playerId); + } + else + { + team.PlayerIds.Add(playerId); + } + + await NotifyTeamsChanged(); + } + + private async Task RandomizeTeams() + { + if (_teams.Count < 2) + return; + + // Clear all teams + foreach (var team in _teams) + { + team.PlayerIds.Clear(); + } + + // Shuffle participants + var shuffled = _availableParticipants.OrderBy(_ => Random.Shared.Next()).ToList(); + + // Distribute evenly + for (int i = 0; i < shuffled.Count; i++) + { + var teamIndex = i % _teams.Count; + _teams[teamIndex].PlayerIds.Add(shuffled[i].PersonId); + } + + await NotifyTeamsChanged(); + } + + private bool IsPlayerInAnyTeam(Guid playerId) => + _teams.Any(t => t.PlayerIds.Contains(playerId)); + + private List GetUnassignedPlayers() => + _availableParticipants.Where(p => !IsPlayerInAnyTeam(p.PersonId)).ToList(); + + private bool HasUnbalancedTeams() + { + if (_teams.Count < 2) + return false; + + var counts = _teams.Select(t => t.PlayerIds.Count).ToList(); + return counts.Max() - counts.Min() > 1; + } + + private async Task NotifyTeamsChanged() + { + var result = _teams.Select(t => new GameTeam + { + Name = t.Name, + PlayerIds = t.PlayerIds.ToList() + }).ToList(); + + await TeamsChanged.InvokeAsync(result); + } + + private string GetPlayerClass(bool isSelected) => + isSelected + ? "mud-theme-primary" + : "mud-paper-outlined"; + + /// + /// Gets the current team assignments. + /// + public IReadOnlyList GetTeams() => _teams.Select(t => new GameTeam + { + Name = t.Name, + PlayerIds = t.PlayerIds.ToList() + }).ToList(); + + /// + /// Validates that all players are assigned to teams. + /// + public bool IsValid() => GetUnassignedPlayers().Count == 0 && _teams.Count >= MinTeams; +}