added Team-Support

Features:
  - Beliebig viele Teams
  - Editierbare Team-Namen
  - "Zufällig verteilen" Button
  - Warnung bei ungleichen Team-Größen
  - Validierung bei Required-Modus
This commit is contained in:
beo3000 2026-01-05 21:07:22 +01:00
parent 3f62b3c0e0
commit caeb1a8e8b
10 changed files with 447 additions and 1 deletions

View File

@ -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
/// <inheritdoc />
public Type GameModelType => typeof(DeathBoxGameModel);
/// <inheritdoc />
public TeamMode TeamMode => TeamMode.None;
/// <inheritdoc />
public int MinTeams => 2;
/// <inheritdoc />
public int? MaxTeams => null;
}

View File

@ -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
/// <inheritdoc />
public Type GameModelType => typeof(FoxHuntGameModel);
/// <inheritdoc />
public TeamMode TeamMode => TeamMode.None;
/// <inheritdoc />
public int MinTeams => 2;
/// <inheritdoc />
public int? MaxTeams => null;
}
}

View File

@ -0,0 +1,17 @@
namespace Koogle.Application.Games;
/// <summary>
/// Represents a team in a game with assigned players.
/// </summary>
public record GameTeam
{
/// <summary>
/// Team name (editable by user).
/// </summary>
public string Name { get; init; } = "";
/// <summary>
/// Player IDs assigned to this team.
/// </summary>
public IReadOnlyList<Guid> PlayerIds { get; init; } = [];
}

View File

@ -33,6 +33,12 @@ public interface IGameSetupModel
/// Game type identifier.
/// </summary>
string GameType { get; }
/// <summary>
/// Team assignments for team-based games.
/// Null when game has no teams.
/// </summary>
IReadOnlyList<GameTeam>? Teams { get; }
}
/// <summary>
@ -51,4 +57,7 @@ public abstract record GameSetupModelBase : IGameSetupModel
/// <inheritdoc />
public abstract string GameType { get; }
/// <inheritdoc />
public IReadOnlyList<GameTeam>? Teams { get; init; }
}

View File

@ -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
/// <inheritdoc />
public Type GameModelType => typeof(ShitGameModel);
/// <inheritdoc />
public TeamMode TeamMode => TeamMode.None;
/// <inheritdoc />
public int MinTeams => 2;
/// <inheritdoc />
public int? MaxTeams => null;
}

View File

@ -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
/// <inheritdoc />
public Type GameModelType => typeof(TrainingGameModel);
/// <inheritdoc />
public TeamMode TeamMode => TeamMode.Optional;
/// <inheritdoc />
public int MinTeams => 2;
/// <inheritdoc />
public int? MaxTeams => null;
}

View File

@ -0,0 +1,22 @@
namespace Koogle.Domain.Enums;
/// <summary>
/// Defines the team mode for a game type.
/// </summary>
public enum TeamMode
{
/// <summary>
/// No teams - individual play only.
/// </summary>
None,
/// <summary>
/// Teams are optional - can play with or without teams.
/// </summary>
Optional,
/// <summary>
/// Teams are required - game cannot start without team assignment.
/// </summary>
Required
}

View File

@ -1,3 +1,5 @@
using Koogle.Domain.Enums;
namespace Koogle.Domain.Interfaces;
/// <summary>
@ -34,4 +36,19 @@ public interface IGameDefinition
/// Type of the game-specific model class.
/// </summary>
Type GameModelType { get; }
/// <summary>
/// Team mode for this game type.
/// </summary>
TeamMode TeamMode { get; }
/// <summary>
/// Minimum number of teams required (when TeamMode != None).
/// </summary>
int MinTeams { get; }
/// <summary>
/// Maximum number of teams allowed (null = unlimited).
/// </summary>
int? MaxTeams { get; }
}

View File

@ -10,6 +10,8 @@
@using Koogle.Web.Store.GameState
@using MudBlazor
@* Team support *@
@inject IState<DayState> DayState
@inject IDispatcher Dispatcher
@inject GameDefinitionRegistry GameRegistry
@ -32,7 +34,20 @@
@bind-SelectedParticipantIds="_selectedParticipantIds"
MinimumParticipants="1" />
</MudExpansionPanel>
@* Step 2b: Team Assignment (only if game supports teams) *@
@if (_selectedDefinition?.TeamMode != TeamMode.None)
{
<MudExpansionPanel Text="2b. Teams"
IsExpanded="@(_currentStep == 3)"
Disabled="@(_selectedParticipantIds.Count == 0)">
<TeamAssignmentSelector AvailableParticipants="@GetSelectedParticipants()"
@bind-Teams="_teams"
MinTeams="@(_selectedDefinition?.MinTeams ?? 2)"
MaxTeams="@_selectedDefinition?.MaxTeams" />
</MudExpansionPanel>
}
@* Step 3: Game-specific Setup *@
<MudExpansionPanel Text="3. Spieleinstellungen"
IsExpanded="@(_currentStep == 2)"
@ -80,6 +95,12 @@
<MudText Typo="Typo.body2">
<strong>Teilnehmer:</strong> @_selectedParticipantIds.Count Spieler
</MudText>
@if (_teams.Count > 0)
{
<MudText Typo="Typo.body2">
<strong>Teams:</strong> @_teams.Count (@string.Join(", ", _teams.Select(t => $"{t.Name}: {t.PlayerIds.Count}")))
</MudText>
}
<MudText Typo="Typo.body2">
<strong>Modus:</strong> @GetThrowModeLabel()
</MudText>
@ -129,6 +150,7 @@
private IGameDefinition? _selectedDefinition;
private IReadOnlyList<DayParticipantDto> _availableParticipants = [];
private IReadOnlyList<Guid> _selectedParticipantIds = [];
private IReadOnlyList<GameTeam> _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<DayParticipantDto> GetSelectedParticipants() =>
_availableParticipants.Where(p => _selectedParticipantIds.Contains(p.PersonId)).ToList();
private Dictionary<string, object> GetSetupParameters()
{
return new Dictionary<string, object>
@ -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;
}

View File

@ -0,0 +1,303 @@
@using Koogle.Application.DTOs
@using Koogle.Application.Games
@using Koogle.Domain.Enums
@using MudBlazor
<MudPaper Class="pa-4" Elevation="0">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-3">
<MudText Typo="Typo.subtitle1">Team-Einteilung</MudText>
<MudStack Row="true" Spacing="1">
<MudButton Size="Size.Small"
Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.Shuffle"
OnClick="RandomizeTeams"
Disabled="@(_teams.Count < 2 || _availableParticipants.Count == 0)">
Zufällig
</MudButton>
<MudButton Size="Size.Small"
Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.Add"
OnClick="AddTeam"
Disabled="@(MaxTeams.HasValue && _teams.Count >= MaxTeams.Value)">
Team
</MudButton>
</MudStack>
</MudStack>
@if (_teams.Count == 0)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
Füge Teams hinzu, um Spieler zuzuweisen.
</MudAlert>
}
else
{
@* Unbalanced teams warning *@
@if (HasUnbalancedTeams())
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
Teams haben unterschiedliche Größen.
</MudAlert>
}
<MudGrid Spacing="3">
@for (int i = 0; i < _teams.Count; i++)
{
var teamIndex = i;
var team = _teams[teamIndex];
<MudItem xs="12" sm="6">
<MudCard Elevation="1">
<MudCardHeader>
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudTextField T="string"
Value="@team.Name"
ValueChanged="@(val => UpdateTeamName(teamIndex, val))"
Variant="Variant.Text"
Margin="Margin.Dense"
Style="max-width: 150px;" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
(@team.PlayerIds.Count)
</MudText>
</MudStack>
</CardHeaderContent>
<CardHeaderActions>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => RemoveTeam(teamIndex))"
Disabled="@(_teams.Count <= MinTeams)" />
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Class="pt-0">
<MudStack Spacing="1">
@foreach (var participant in _availableParticipants)
{
var isInThisTeam = team.PlayerIds.Contains(participant.PersonId);
var isInAnyTeam = IsPlayerInAnyTeam(participant.PersonId);
<MudPaper Class="@GetPlayerClass(isInThisTeam)"
Style="cursor: pointer;"
Elevation="0"
@onclick="() => TogglePlayerInTeam(teamIndex, participant.PersonId)">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="pa-1">
<MudCheckBox T="bool"
Value="@isInThisTeam"
ValueChanged="@((bool _) => TogglePlayerInTeam(teamIndex, participant.PersonId))"
Color="Color.Primary"
Dense="true"
DisableRipple="true" />
<MudText Typo="Typo.body2"
Style="@(isInAnyTeam && !isInThisTeam ? "opacity: 0.5;" : "")">
@participant.PersonName
</MudText>
</MudStack>
</MudPaper>
}
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
}
</MudGrid>
}
@* Summary *@
@if (_teams.Count > 0)
{
var unassigned = GetUnassignedPlayers().Count;
<MudText Typo="Typo.caption" Color="@(unassigned > 0 ? Color.Warning : Color.Secondary)" Class="mt-3">
@if (unassigned > 0)
{
@($"{unassigned} Spieler nicht zugewiesen")
}
else
{
@("Alle Spieler zugewiesen")
}
</MudText>
}
</MudPaper>
@code {
/// <summary>
/// List of available participants from the day.
/// </summary>
[Parameter]
public IReadOnlyList<DayParticipantDto> AvailableParticipants { get; set; } = [];
/// <summary>
/// Current team assignments.
/// </summary>
[Parameter]
public IReadOnlyList<GameTeam> Teams { get; set; } = [];
/// <summary>
/// Callback when teams change.
/// </summary>
[Parameter]
public EventCallback<IReadOnlyList<GameTeam>> TeamsChanged { get; set; }
/// <summary>
/// Minimum number of teams required.
/// </summary>
[Parameter]
public int MinTeams { get; set; } = 2;
/// <summary>
/// Maximum number of teams allowed (null = unlimited).
/// </summary>
[Parameter]
public int? MaxTeams { get; set; }
private List<DayParticipantDto> _availableParticipants = [];
private List<MutableTeam> _teams = [];
private class MutableTeam
{
public string Name { get; set; } = "";
public HashSet<Guid> 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<DayParticipantDto> 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";
/// <summary>
/// Gets the current team assignments.
/// </summary>
public IReadOnlyList<GameTeam> GetTeams() => _teams.Select(t => new GameTeam
{
Name = t.Name,
PlayerIds = t.PlayerIds.ToList()
}).ToList();
/// <summary>
/// Validates that all players are assigned to teams.
/// </summary>
public bool IsValid() => GetUnassignedPlayers().Count == 0 && _teams.Count >= MinTeams;
}