Compare commits

...

3 Commits

Author SHA1 Message Date
Tim Krampitz
732b03edb4 fix(game): aktualisiere Handkarten nach gespieltem Slip
Some checks failed
Build / build (push) Has been cancelled
OpenWiki Update / update (push) Has been cancelled
2026-09-10 22:03:36 +02:00
Tim Krampitz
01b6a41bc6 fix(lobby): ready status anzeigen und Spielstart absichern 2026-09-10 21:52:21 +02:00
Tim Krampitz
01e62652f5 ObservableProperty entfernt, Properties refaktoriert
In allen betroffenen ViewModels wurden die mit [ObservableProperty] markierten Felder entfernt und durch public partial Properties mit { get; set; } ersetzt. Property-Namen wurden vereinheitlicht (z.B. IsChallengeVisible, PendingChallenge, SyncStatusText). In LobbyViewModel.cs wird nun geprüft, ob alle Spieler bereit sind, bevor das Spiel gestartet werden kann (CanStartGame). Die Behandlung von StateChanged-Messages aktualisiert relevante Properties. In SlipItIn.csproj wurde das WarningLevel für alle Konfigurationen explizit auf 1 gesetzt.
2026-09-10 21:50:46 +02:00
12 changed files with 117 additions and 32 deletions

View File

@@ -104,11 +104,18 @@ public class GameHubTests
var game = new Game { Id = 7, LobbyCode = "XYZ789", HostId = user.Id }; var game = new Game { Id = 7, LobbyCode = "XYZ789", HostId = user.Id };
_gameService.Setup(s => s.CreateGameAsync(user.Id, ConnectionId)).ReturnsAsync(game); _gameService.Setup(s => s.CreateGameAsync(user.Id, ConnectionId)).ReturnsAsync(game);
_gameService.Setup(s => s.GetGameStateAsync(game.Id)).ReturnsAsync(new GameStateDto
{
GameId = game.Id,
LobbyCode = game.LobbyCode,
Players = [new PlayerInfoDto { Username = user.Username }]
});
await _hub.CreateLobby(); await _hub.CreateLobby();
_groups.Verify(g => g.AddToGroupAsync(ConnectionId, "XYZ789", default), Times.Once); _groups.Verify(g => g.AddToGroupAsync(ConnectionId, "XYZ789", default), Times.Once);
_callerProxy.Verify(p => p.SendCoreAsync("LobbyCreated", It.Is<object?[]>(a => a.Length == 1), default), Times.Once); _callerProxy.Verify(p => p.SendCoreAsync("LobbyCreated", It.Is<object?[]>(a => a.Length == 1), default), Times.Once);
_callerProxy.Verify(p => p.SendCoreAsync("PlayerJoined", It.Is<object?[]>(a => a.Length == 1), default), Times.Once);
} }
[Fact] [Fact]
@@ -317,10 +324,12 @@ public class GameHubTests
_gameService.Setup(s => s.SubmitSlipAsync(game.Id, player.Id, 55)).ReturnsAsync(true); _gameService.Setup(s => s.SubmitSlipAsync(game.Id, player.Id, 55)).ReturnsAsync(true);
_gameService.Setup(s => s.GetGameAsync(game.Id)).ReturnsAsync(new Game { Id = game.Id, LobbyCode = game.LobbyCode }); _gameService.Setup(s => s.GetGameAsync(game.Id)).ReturnsAsync(new Game { Id = game.Id, LobbyCode = game.LobbyCode });
_gameService.Setup(s => s.GetPlayerHandAsync(player.Id)).ReturnsAsync(new PlayerHandDto { PlayerId = player.Id });
await _hub.SubmitSlip(game.Id, player.Id, 55); await _hub.SubmitSlip(game.Id, player.Id, 55);
_groupProxy.Verify(p => p.SendCoreAsync("SlipSubmitted", It.IsAny<object?[]>(), default), Times.Once); _groupProxy.Verify(p => p.SendCoreAsync("SlipSubmitted", It.IsAny<object?[]>(), default), Times.Once);
_callerProxy.Verify(p => p.SendCoreAsync("PlayerHandUpdated", It.IsAny<object?[]>(), default), Times.Once);
} }
[Fact] [Fact]

View File

@@ -118,8 +118,9 @@ public class GameServiceTests
using var db = _factory.CreateDbContext(); using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db); var host = TestDbHelper.SeedUser(db);
var other = TestDbHelper.SeedUser(db, "other", "other@t.local"); var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
var (game, _) = TestDbHelper.SeedGame(db, host); var (game, hostPlayer) = TestDbHelper.SeedGame(db, host);
db.Players.Add(new Player { UserId = other.Id, GameId = game.Id }); hostPlayer.IsReady = true;
db.Players.Add(new Player { UserId = other.Id, GameId = game.Id, IsReady = true });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var result = await _sut.StartGameAsync(game.Id); var result = await _sut.StartGameAsync(game.Id);
@@ -131,6 +132,22 @@ public class GameServiceTests
Assert.Equal(RoundStatus.Waiting, round.Status); Assert.Equal(RoundStatus.Waiting, round.Status);
} }
[Fact]
public async Task StartGameAsync_Throws_WhenAnyPlayerIsNotReady()
{
using var db = _factory.CreateDbContext();
var host = TestDbHelper.SeedUser(db);
var other = TestDbHelper.SeedUser(db, "other", "other@t.local");
var (game, hostPlayer) = TestDbHelper.SeedGame(db, host);
hostPlayer.IsReady = true;
db.Players.Add(new Player { UserId = other.Id, GameId = game.Id, IsReady = false });
await db.SaveChangesAsync();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.StartGameAsync(game.Id));
Assert.Equal("All players must be ready to start the game", exception.Message);
}
[Fact] [Fact]
public async Task StartGameAsync_Throws_WhenGameNotFound() public async Task StartGameAsync_Throws_WhenGameNotFound()
{ {

View File

@@ -110,6 +110,8 @@ public class GameHub : Hub
await Groups.AddToGroupAsync(connectionId, game.LobbyCode); await Groups.AddToGroupAsync(connectionId, game.LobbyCode);
await Clients.Caller.SendAsync("LobbyCreated", new { GameId = game.Id, LobbyCode = game.LobbyCode }); await Clients.Caller.SendAsync("LobbyCreated", new { GameId = game.Id, LobbyCode = game.LobbyCode });
var gameState = await _gameService.GetGameStateAsync(game.Id);
await Clients.Caller.SendAsync("PlayerJoined", gameState);
_logger.LogInformation("Lobby {LobbyCode} created by user {UserId}", game.LobbyCode, userId); _logger.LogInformation("Lobby {LobbyCode} created by user {UserId}", game.LobbyCode, userId);
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
@@ -231,6 +233,7 @@ public class GameHub : Hub
var result = await _gameService.SubmitSlipAsync(gameId, playerId, cardId); var result = await _gameService.SubmitSlipAsync(gameId, playerId, cardId);
var game = await _gameService.GetGameAsync(gameId); var game = await _gameService.GetGameAsync(gameId);
var hand = await _gameService.GetPlayerHandAsync(playerId);
await Clients.Group(game.LobbyCode).SendAsync("SlipSubmitted", new await Clients.Group(game.LobbyCode).SendAsync("SlipSubmitted", new
{ {
@@ -238,6 +241,7 @@ public class GameHub : Hub
CardId = cardId, CardId = cardId,
Success = result Success = result
}); });
await Clients.Caller.SendAsync("PlayerHandUpdated", hand);
} }
catch (UnauthorizedAccessException ex) catch (UnauthorizedAccessException ex)
{ {

View File

@@ -81,6 +81,9 @@ public class GameService : IGameService
if (game.Players.Count < 2) if (game.Players.Count < 2)
throw new InvalidOperationException("At least 2 players are required to start the game"); throw new InvalidOperationException("At least 2 players are required to start the game");
if (game.Players.Any(player => !player.IsReady))
throw new InvalidOperationException("All players must be ready to start the game");
game.Status = GameStatus.InProgress; game.Status = GameStatus.InProgress;
game.StartedAt = DateTime.UtcNow; game.StartedAt = DateTime.UtcNow;

View File

@@ -104,4 +104,36 @@
<AndroidManifest>Platforms\Android\AndroidManifest.Release.xml</AndroidManifest> <AndroidManifest>Platforms\Android\AndroidManifest.Release.xml</AndroidManifest>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-android|AnyCPU'">
<WarningLevel>1</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-ios|AnyCPU'">
<WarningLevel>1</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-maccatalyst|AnyCPU'">
<WarningLevel>1</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-windows10.0.19041.0|AnyCPU'">
<WarningLevel>1</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
<WarningLevel>1</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-ios|AnyCPU'">
<WarningLevel>1</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-maccatalyst|AnyCPU'">
<WarningLevel>1</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-windows10.0.19041.0|AnyCPU'">
<WarningLevel>1</WarningLevel>
</PropertyGroup>
</Project> </Project>

View File

@@ -23,31 +23,31 @@ public partial class GameBoardViewModel : BaseViewModel,
private DateTime _lastStateUtc = DateTime.UtcNow; private DateTime _lastStateUtc = DateTime.UtcNow;
[ObservableProperty] [ObservableProperty]
private int gameId; private partial int GameId { get; set; }
[ObservableProperty] [ObservableProperty]
private int currentRound; private partial int CurrentRound { get; set; }
[ObservableProperty] [ObservableProperty]
private int roundTimeRemaining; private partial int RoundTimeRemaining { get; set; }
[ObservableProperty] [ObservableProperty]
private bool isChallengeVisible; private partial bool IsConnected { get; set; }
[ObservableProperty] [ObservableProperty]
private SlipChallengeDto? pendingChallenge; private partial bool IsSyncing { get; set; }
[ObservableProperty] [ObservableProperty]
private bool isConnected; public partial bool IsChallengeVisible { get; set; }
[ObservableProperty] [ObservableProperty]
private bool isSyncing; public partial SlipChallengeDto? PendingChallenge { get; set; }
[ObservableProperty] [ObservableProperty]
private string syncStatusText = "Bereit"; public partial string SyncStatusText { get; set; } = "Bereit";
[ObservableProperty] [ObservableProperty]
private int pendingSyncActions; public partial int PendingSyncActions { get; set; }
public ObservableCollection<PlayerCardDto> MyCards { get; } = []; public ObservableCollection<PlayerCardDto> MyCards { get; } = [];
public ObservableCollection<PlayerInfoDto> OtherPlayers { get; } = []; public ObservableCollection<PlayerInfoDto> OtherPlayers { get; } = [];
@@ -212,7 +212,7 @@ public partial class GameBoardViewModel : BaseViewModel,
MainThread.BeginInvokeOnMainThread(() => MainThread.BeginInvokeOnMainThread(() =>
{ {
MyCards.Clear(); MyCards.Clear();
foreach (var card in message.Value.Cards) foreach (var card in message.Value.Cards.Where(card => !card.IsUsed))
MyCards.Add(card); MyCards.Add(card);
}); });
} }
@@ -233,12 +233,12 @@ public partial class GameBoardViewModel : BaseViewModel,
public void Receive(SyncStateChangedMessage message) public void Receive(SyncStateChangedMessage message)
{ {
MainThread.BeginInvokeOnMainThread(() => MainThread.BeginInvokeOnMainThread((Action)(() =>
{ {
IsSyncing = message.Value.IsSyncing; IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText; SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions; PendingSyncActions = message.Value.PendingActions;
}); }));
} }
public void Receive(ConnectionStateChangedMessage message) public void Receive(ConnectionStateChangedMessage message)

View File

@@ -22,25 +22,31 @@ public partial class LobbyViewModel : BaseViewModel,
private readonly IApiService _apiService; private readonly IApiService _apiService;
[ObservableProperty] [ObservableProperty]
private string lobbyCode = string.Empty; public partial string LobbyCode { get; set; } = string.Empty;
[ObservableProperty] [ObservableProperty]
private int gameId; public partial int GameId { get; set; }
[ObservableProperty] [ObservableProperty]
private bool isHost; public partial bool IsHost { get; set; }
[ObservableProperty] [ObservableProperty]
private bool isConnected; public partial bool IsConnected { get; set; }
[ObservableProperty] [ObservableProperty]
private bool isSyncing; public partial bool IsSyncing { get; set; }
[ObservableProperty] [ObservableProperty]
private string syncStatusText = "Bereit"; public partial string SyncStatusText { get; set; } = "Bereit";
[ObservableProperty] [ObservableProperty]
private int pendingSyncActions; public partial int PendingSyncActions { get; set; }
[ObservableProperty]
public partial bool IsCurrentPlayerReady { get; set; }
[ObservableProperty]
public partial bool CanStartGame { get; set; }
public ObservableCollection<PlayerInfoDto> Players { get; } = []; public ObservableCollection<PlayerInfoDto> Players { get; } = [];
@@ -106,6 +112,9 @@ public partial class LobbyViewModel : BaseViewModel,
if (GameId <= 0) if (GameId <= 0)
throw new InvalidOperationException("Keine aktive Lobby gefunden."); throw new InvalidOperationException("Keine aktive Lobby gefunden.");
if (!CanStartGame)
throw new InvalidOperationException("Alle Spieler müssen bereit sein, bevor das Spiel gestartet werden kann.");
await _signalR.StartGameAsync(GameId); await _signalR.StartGameAsync(GameId);
}); });
} }
@@ -133,6 +142,11 @@ public partial class LobbyViewModel : BaseViewModel,
Players.Clear(); Players.Clear();
foreach (var player in message.Value.Players) foreach (var player in message.Value.Players)
Players.Add(player); Players.Add(player);
var currentUsername = _authSession.CurrentUser?.Username;
IsCurrentPlayerReady = message.Value.Players.Any(p =>
p.IsReady && string.Equals(p.Username, currentUsername, StringComparison.OrdinalIgnoreCase));
CanStartGame = message.Value.Players.Count >= 2 && message.Value.Players.All(p => p.IsReady);
}); });
} }

View File

@@ -14,10 +14,10 @@ public partial class LoginViewModel : BaseViewModel
private readonly IGameStateService _gameStateService; private readonly IGameStateService _gameStateService;
[ObservableProperty] [ObservableProperty]
private string email = string.Empty; public partial string Email { get; set; } = string.Empty;
[ObservableProperty] [ObservableProperty]
private string password = string.Empty; public partial string Password { get; set; } = string.Empty;
public LoginViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR, IGameStateService gameStateService) public LoginViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR, IGameStateService gameStateService)
{ {

View File

@@ -13,13 +13,13 @@ public partial class RegisterViewModel : BaseViewModel
private readonly ISignalRService _signalR; private readonly ISignalRService _signalR;
[ObservableProperty] [ObservableProperty]
private string username = string.Empty; public partial string Username { get; set; } = string.Empty;
[ObservableProperty] [ObservableProperty]
private string email = string.Empty; public partial string Email { get; set; } = string.Empty;
[ObservableProperty] [ObservableProperty]
private string password = string.Empty; public partial string Password { get; set; } = string.Empty;
public RegisterViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR) public RegisterViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR)
{ {

View File

@@ -3,8 +3,9 @@
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:SlipItIn.ViewModels" xmlns:vm="clr-namespace:SlipItIn.ViewModels"
x:Class="SlipItIn.Views.ChallengeNotificationOverlay" x:Class="SlipItIn.Views.ChallengeNotificationOverlay"
x:DataType="vm:GameBoardViewModel"> x:DataType="vm:GameBoardViewModel"
<Grid BackgroundColor="#88000000" IsVisible="{Binding IsChallengeVisible}" Padding="24"> IsVisible="{Binding IsChallengeVisible}">
<Grid BackgroundColor="#88000000" Padding="24">
<Border VerticalOptions="Center" HorizontalOptions="Center" Stroke="LightGray" StrokeShape="RoundRectangle 12" BackgroundColor="White" Padding="16"> <Border VerticalOptions="Center" HorizontalOptions="Center" Stroke="LightGray" StrokeShape="RoundRectangle 12" BackgroundColor="White" Padding="16">
<VerticalStackLayout Spacing="10"> <VerticalStackLayout Spacing="10">
<Label Text="Du wurdest beschuldigt" FontAttributes="Bold" /> <Label Text="Du wurdest beschuldigt" FontAttributes="Bold" />

View File

@@ -18,6 +18,9 @@
<Label Text="Meine Phrasen" FontAttributes="Bold" /> <Label Text="Meine Phrasen" FontAttributes="Bold" />
<CollectionView ItemsSource="{Binding MyCards}"> <CollectionView ItemsSource="{Binding MyCards}">
<CollectionView.EmptyView>
<Label Text="Keine ungespielten Phrasen mehr." />
</CollectionView.EmptyView>
<CollectionView.ItemTemplate> <CollectionView.ItemTemplate>
<DataTemplate> <DataTemplate>
<Border Margin="0,4" Padding="10" Stroke="LightGray" StrokeThickness="1" StrokeShape="RoundRectangle 10" BackgroundColor="White"> <Border Margin="0,4" Padding="10" Stroke="LightGray" StrokeThickness="1" StrokeShape="RoundRectangle 10" BackgroundColor="White">

View File

@@ -21,16 +21,18 @@
<Label Text="{Binding LobbyCode, StringFormat='Code: {0}'}" /> <Label Text="{Binding LobbyCode, StringFormat='Code: {0}'}" />
<Button Text="Bereit" Command="{Binding SetReadyCommand}" /> <Button Text="Bereit" Command="{Binding SetReadyCommand}" />
<Button Text="Spiel starten (Host)" Command="{Binding StartGameCommand}" IsVisible="{Binding IsHost}" /> <Label Text="Du bist bereit." TextColor="ForestGreen" IsVisible="{Binding IsCurrentPlayerReady}" />
<Button Text="Spiel starten (Host)" Command="{Binding StartGameCommand}" IsVisible="{Binding IsHost}" IsEnabled="{Binding CanStartGame}" />
<Label Text="Spieler" FontAttributes="Bold" /> <Label Text="Spieler" FontAttributes="Bold" />
<CollectionView ItemsSource="{Binding Players}"> <CollectionView ItemsSource="{Binding Players}">
<CollectionView.ItemTemplate> <CollectionView.ItemTemplate>
<DataTemplate> <DataTemplate>
<Grid ColumnDefinitions="*,Auto,Auto" Padding="4"> <Grid ColumnDefinitions="*,Auto,Auto,Auto" Padding="4">
<Label Grid.Column="0" Text="{Binding Username}" /> <Label Grid.Column="0" Text="{Binding Username}" />
<Label Grid.Column="1" Text="Score:" /> <Label Grid.Column="1" Text="Bereit" TextColor="ForestGreen" IsVisible="{Binding IsReady}" />
<Label Grid.Column="2" Text="{Binding Score}" /> <Label Grid.Column="2" Text="Score:" />
<Label Grid.Column="3" Text="{Binding Score}" />
</Grid> </Grid>
</DataTemplate> </DataTemplate>
</CollectionView.ItemTemplate> </CollectionView.ItemTemplate>