From 68ea66d4f9611329a64d9662bc5216a31c615407 Mon Sep 17 00:00:00 2001 From: Jan-Marlon Leibl Date: Thu, 27 Mar 2025 15:22:24 +0100 Subject: [PATCH] feat: add stand and get game features to blackjack game --- backend/requests/blackjack.http | 8 ++ .../blackjack/BlackJackGameController.java | 38 +++++- .../casino/blackjack/BlackJackGameEntity.java | 5 +- .../casino/blackjack/BlackJackService.java | 66 +++++++++- .../szut/casino/blackjack/BlackJackState.java | 1 + .../de/szut/casino/blackjack/CardEntity.java | 4 + .../game/blackjack/blackjack.component.html | 9 ++ .../game/blackjack/blackjack.component.ts | 61 +++++++++ .../game-controls/game-controls.component.ts | 122 ++++++++++++++--- .../game-result/game-result.component.css | 1 + .../game-result/game-result.component.spec.ts | 23 ++++ .../game-result/game-result.component.ts | 124 ++++++++++++++++++ .../blackjack/services/blackjack.service.ts | 11 ++ 13 files changed, 444 insertions(+), 29 deletions(-) create mode 100644 frontend/src/app/feature/game/blackjack/components/game-result/game-result.component.css create mode 100644 frontend/src/app/feature/game/blackjack/components/game-result/game-result.component.spec.ts create mode 100644 frontend/src/app/feature/game/blackjack/components/game-result/game-result.component.ts diff --git a/backend/requests/blackjack.http b/backend/requests/blackjack.http index 1ac2def..16f7122 100644 --- a/backend/requests/blackjack.http +++ b/backend/requests/blackjack.http @@ -10,3 +10,11 @@ Content-Type: application/json POST http://localhost:8080/blackjack/202/hit Authorization: Bearer {{token}} +### +POST http://localhost:8080/blackjack/202/stand +Authorization: Bearer {{token}} + +### +GET http://localhost:8080/blackjack/202 +Authorization: Bearer {{token}} + diff --git a/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameController.java b/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameController.java index 58505eb..9f1dae9 100644 --- a/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameController.java +++ b/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameController.java @@ -26,6 +26,23 @@ public class BlackJackGameController { this.userService = userService; } + @GetMapping("/blackjack/{id}") + public ResponseEntity getGame(@PathVariable Long id, @RequestHeader("Authorization") String token) { + Optional optionalUser = userService.getCurrentUser(token); + + if (optionalUser.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + UserEntity user = optionalUser.get(); + BlackJackGameEntity game = blackJackService.getBlackJackGame(id); + if (game == null || !Objects.equals(game.getUserId(), user.getId())) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(game); + } + @PostMapping("/blackjack/{id}/hit") public ResponseEntity hit(@PathVariable Long id, @RequestHeader("Authorization") String token) { Optional optionalUser = userService.getCurrentUser(token); @@ -40,13 +57,24 @@ public class BlackJackGameController { return ResponseEntity.notFound().build(); } - if (game.getState() != BlackJackState.IN_PROGRESS) { - Map errorResponse = new HashMap<>(); - errorResponse.put("error", "Invalid state"); - return ResponseEntity.badRequest().body(errorResponse); + return ResponseEntity.ok(blackJackService.hit(game)); + } + + @PostMapping("/blackjack/{id}/stand") + public ResponseEntity stand(@PathVariable Long id, @RequestHeader("Authorization") String token) { + Optional optionalUser = userService.getCurrentUser(token); + + if (optionalUser.isEmpty()) { + return ResponseEntity.notFound().build(); } - return ResponseEntity.ok(blackJackService.hit(game)); + UserEntity user = optionalUser.get(); + BlackJackGameEntity game = blackJackService.getBlackJackGame(id); + if (game == null || !Objects.equals(game.getUserId(), user.getId())) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(blackJackService.stand(game)); } @PostMapping("/blackjack/start") diff --git a/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameEntity.java b/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameEntity.java index eb5a704..0dbfcc0 100644 --- a/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameEntity.java +++ b/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameEntity.java @@ -24,12 +24,15 @@ public class BlackJackGameEntity { @GeneratedValue private Long id; + @Version + @JsonIgnore + private Long version; + @ManyToOne @JoinColumn(name = "user_id", nullable = false) @JsonIgnore private UserEntity user; - // Expose UserID to JSON output public Long getUserId() { return user != null ? user.getId() : null; } diff --git a/backend/src/main/java/de/szut/casino/blackjack/BlackJackService.java b/backend/src/main/java/de/szut/casino/blackjack/BlackJackService.java index df1a459..801acaa 100644 --- a/backend/src/main/java/de/szut/casino/blackjack/BlackJackService.java +++ b/backend/src/main/java/de/szut/casino/blackjack/BlackJackService.java @@ -3,6 +3,7 @@ package de.szut.casino.blackjack; import de.szut.casino.user.UserEntity; import de.szut.casino.user.UserRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.util.List; @@ -26,6 +27,7 @@ public class BlackJackService { return optionalBlackJackGame.orElse(null); } + @Transactional public BlackJackGameEntity createBlackJackGame(UserEntity user, BigDecimal betAmount) { BlackJackGameEntity game = new BlackJackGameEntity(); game.setUser(user); @@ -46,21 +48,79 @@ public class BlackJackService { user.setBalance(user.getBalance().subtract(betAmount)); userRepository.save(user); - blackJackGameRepository.save(game); - - return game; + return blackJackGameRepository.save(game); } + @Transactional public BlackJackGameEntity hit(BlackJackGameEntity game) { + game = blackJackGameRepository.findById(game.getId()).orElse(game); + + if (game.getState() != BlackJackState.IN_PROGRESS) { + return game; + } + CardEntity drawnCard = drawCardFromDeck(game); drawnCard.setCardType(CardType.PLAYER); game.getPlayerCards().add(drawnCard); game.setState(getState(game)); + if (game.getState() == BlackJackState.PLAYER_WON) { + updateUserBalance(game, true); + } else if (game.getState() == BlackJackState.PLAYER_LOST) { + updateUserBalance(game, false); + } + return blackJackGameRepository.save(game); } + @Transactional + public BlackJackGameEntity stand(BlackJackGameEntity game) { + game = blackJackGameRepository.findById(game.getId()).orElse(game); + + if (game.getState() != BlackJackState.IN_PROGRESS) { + return game; + } + + while (calculateHandValue(game.getDealerCards()) < 17) { + CardEntity dealerCard = drawCardFromDeck(game); + dealerCard.setCardType(CardType.DEALER); + game.getDealerCards().add(dealerCard); + } + + int playerValue = calculateHandValue(game.getPlayerCards()); + int dealerValue = calculateHandValue(game.getDealerCards()); + + if (dealerValue > 21 || playerValue > dealerValue) { + game.setState(BlackJackState.PLAYER_WON); + updateUserBalance(game, true); + } else { + game.setState(BlackJackState.DRAW); + updateUserBalance(game, false); + } + + return blackJackGameRepository.save(game); + } + + @Transactional + private void updateUserBalance(BlackJackGameEntity game, boolean isWin) { + UserEntity user = game.getUser(); + user = userRepository.findById(user.getId()).orElse(user); + BigDecimal balance = user.getBalance(); + + BigDecimal betAmount = game.getBet(); + + if (isWin) { + balance = balance.add(betAmount.multiply(BigDecimal.valueOf(2))); + } + else if (game.getState() == BlackJackState.DRAW) { + balance = balance.add(betAmount); + } + + user.setBalance(balance); + userRepository.save(user); + } + private void initializeDeck(BlackJackGameEntity game) { for (Suit suit : Suit.values()) { for (Rank rank : Rank.values()) { diff --git a/backend/src/main/java/de/szut/casino/blackjack/BlackJackState.java b/backend/src/main/java/de/szut/casino/blackjack/BlackJackState.java index 2ba802c..e1d422d 100644 --- a/backend/src/main/java/de/szut/casino/blackjack/BlackJackState.java +++ b/backend/src/main/java/de/szut/casino/blackjack/BlackJackState.java @@ -4,4 +4,5 @@ public enum BlackJackState { PLAYER_WON, IN_PROGRESS, PLAYER_LOST, + DRAW, } diff --git a/backend/src/main/java/de/szut/casino/blackjack/CardEntity.java b/backend/src/main/java/de/szut/casino/blackjack/CardEntity.java index 5520b58..27d09d6 100644 --- a/backend/src/main/java/de/szut/casino/blackjack/CardEntity.java +++ b/backend/src/main/java/de/szut/casino/blackjack/CardEntity.java @@ -19,6 +19,10 @@ public class CardEntity { @JsonIgnore private Long id; + @Version + @JsonIgnore + private Long version; + @ManyToOne @JoinColumn(name = "game_id", nullable = false) @JsonBackReference diff --git a/frontend/src/app/feature/game/blackjack/blackjack.component.html b/frontend/src/app/feature/game/blackjack/blackjack.component.html index ae130d4..27860a1 100644 --- a/frontend/src/app/feature/game/blackjack/blackjack.component.html +++ b/frontend/src/app/feature/game/blackjack/blackjack.component.html @@ -7,6 +7,8 @@ @if (gameInProgress()) { + + + diff --git a/frontend/src/app/feature/game/blackjack/blackjack.component.ts b/frontend/src/app/feature/game/blackjack/blackjack.component.ts index 174c358..2e29eb0 100644 --- a/frontend/src/app/feature/game/blackjack/blackjack.component.ts +++ b/frontend/src/app/feature/game/blackjack/blackjack.component.ts @@ -10,6 +10,8 @@ import { GameControlsComponent } from './components/game-controls/game-controls. import { GameInfoComponent } from './components/game-info/game-info.component'; import { Card, BlackjackGame } from './models/blackjack.model'; import { BlackjackService } from './services/blackjack.service'; +import { HttpErrorResponse } from '@angular/common/http'; +import { GameResultComponent } from './components/game-result/game-result.component'; @Component({ selector: 'app-blackjack', @@ -22,6 +24,7 @@ import { BlackjackService } from './services/blackjack.service'; PlayerHandComponent, GameControlsComponent, GameInfoComponent, + GameResultComponent, ], templateUrl: './blackjack.component.html', changeDetection: ChangeDetectionStrategy.OnPush, @@ -37,8 +40,14 @@ export default class BlackjackComponent { balance = signal(0); currentGameId = signal(undefined); gameInProgress = signal(false); + gameState = signal('IN_PROGRESS'); + showGameResult = signal(false); constructor() { + this.refreshUserBalance(); + } + + private refreshUserBalance(): void { this.userService.getCurrentUser().subscribe((user) => { this.balance.set(user?.balance ?? 0); }); @@ -49,6 +58,7 @@ export default class BlackjackComponent { this.currentGameId.set(game.id); this.currentBet.set(game.bet); this.gameInProgress.set(game.state === 'IN_PROGRESS'); + this.gameState.set(game.state); this.dealerCards.set( game.dealerCards.map((card, index) => ({ @@ -63,12 +73,20 @@ export default class BlackjackComponent { hidden: false, })) ); + + if (game.state !== 'IN_PROGRESS') { + this.refreshUserBalance(); + setTimeout(() => { + this.showGameResult.set(true); + }, 1000); + } } onNewGame(bet: number): void { this.blackjackService.startGame(bet).subscribe({ next: (game) => { this.updateGameState(game); + this.refreshUserBalance(); }, error: (error) => { console.error('Failed to start game:', error); @@ -85,12 +103,18 @@ export default class BlackjackComponent { }, error: (error) => { console.error('Failed to hit:', error); + this.handleGameError(error); }, }); } onStand(): void { if (!this.currentGameId()) return; + + if (this.gameState() !== 'IN_PROGRESS') { + console.log('Cannot stand: game is not in progress'); + return; + } this.blackjackService.stand(this.currentGameId()!).subscribe({ next: (game) => { @@ -98,10 +122,47 @@ export default class BlackjackComponent { }, error: (error) => { console.error('Failed to stand:', error); + this.handleGameError(error); }, }); } + onCloseGameResult(): void { + this.showGameResult.set(false); + } + + private handleGameError(error: any): void { + if (error instanceof HttpErrorResponse) { + if (error.status === 400 && error.error?.error === 'Invalid state') { + this.gameInProgress.set(false); + + this.refreshUserBalance(); + } + else if (error.status === 500) { + console.log('Server error occurred. The game may have been updated in another session.'); + + this.gameInProgress.set(false); + + this.refreshUserBalance(); + + if (this.currentGameId()) { + this.refreshGameState(this.currentGameId()!); + } + } + } + } + + private refreshGameState(gameId: number): void { + this.blackjackService.getGame(gameId).subscribe({ + next: (game) => { + this.updateGameState(game); + }, + error: (err) => { + console.error('Failed to refresh game state:', err); + } + }); + } + leaveGame(): void { this.router.navigate(['/home']); } diff --git a/frontend/src/app/feature/game/blackjack/components/game-controls/game-controls.component.ts b/frontend/src/app/feature/game/blackjack/components/game-controls/game-controls.component.ts index c78111f..73c5928 100644 --- a/frontend/src/app/feature/game/blackjack/components/game-controls/game-controls.component.ts +++ b/frontend/src/app/feature/game/blackjack/components/game-controls/game-controls.component.ts @@ -1,36 +1,118 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { Card } from '../../models/blackjack.model'; @Component({ selector: 'app-game-controls', standalone: true, imports: [CommonModule], template: ` -
- - - +
+
+
+
Deine Punkte: {{ calculateHandValue(playerCards) }}
+
+ Status: {{ getStatusText(gameState) }} +
+
+
+
+ + + +
`, changeDetection: ChangeDetectionStrategy.OnPush, }) export class GameControlsComponent { + @Input() playerCards: Card[] = []; + @Input() gameState: string = 'IN_PROGRESS'; + @Output() hit = new EventEmitter(); @Output() stand = new EventEmitter(); @Output() leave = new EventEmitter(); + + calculateHandValue(cards: Card[]): number { + let sum = 0; + let aceCount = 0; + + const rankValues: Record = { + TWO: 2, + THREE: 3, + FOUR: 4, + FIVE: 5, + SIX: 6, + SEVEN: 7, + EIGHT: 8, + NINE: 9, + TEN: 10, + JACK: 10, + QUEEN: 10, + KING: 10, + ACE: 11 + }; + + for (const card of cards) { + if (!card.hidden) { + const value = rankValues[card.rank] || 0; + sum += value; + if (card.rank === 'ACE') { + aceCount++; + } + } + } + + while (sum > 21 && aceCount > 0) { + sum -= 10; + aceCount--; + } + + return sum; + } + + getStatusText(state: string): string { + switch (state) { + case 'IN_PROGRESS': + return 'Spiel läuft'; + case 'PLAYER_WON': + return 'Gewonnen!'; + case 'PLAYER_LOST': + return 'Verloren!'; + case 'DRAW': + return 'Unentschieden!'; + default: + return state; + } + } + + getStatusClass(state: string): string { + switch (state) { + case 'PLAYER_WON': + return 'text-emerald'; + case 'PLAYER_LOST': + return 'text-accent-red'; + case 'DRAW': + return 'text-yellow-400'; + default: + return 'text-white'; + } + } } diff --git a/frontend/src/app/feature/game/blackjack/components/game-result/game-result.component.css b/frontend/src/app/feature/game/blackjack/components/game-result/game-result.component.css new file mode 100644 index 0000000..5c8977f --- /dev/null +++ b/frontend/src/app/feature/game/blackjack/components/game-result/game-result.component.css @@ -0,0 +1 @@ +/* No custom styles needed */ diff --git a/frontend/src/app/feature/game/blackjack/components/game-result/game-result.component.spec.ts b/frontend/src/app/feature/game/blackjack/components/game-result/game-result.component.spec.ts new file mode 100644 index 0000000..fbc1d3d --- /dev/null +++ b/frontend/src/app/feature/game/blackjack/components/game-result/game-result.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GameResultComponent } from './game-result.component'; + +describe('GameResultComponent', () => { + let component: GameResultComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GameResultComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GameResultComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/feature/game/blackjack/components/game-result/game-result.component.ts b/frontend/src/app/feature/game/blackjack/components/game-result/game-result.component.ts new file mode 100644 index 0000000..15c1d8f --- /dev/null +++ b/frontend/src/app/feature/game/blackjack/components/game-result/game-result.component.ts @@ -0,0 +1,124 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { CommonModule, CurrencyPipe } from '@angular/common'; +import { animate, style, transition, trigger } from '@angular/animations'; + +@Component({ + selector: 'app-game-result', + standalone: true, + imports: [CommonModule, CurrencyPipe], + template: ` + + `, + styleUrls: ['./game-result.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger('fadeInOut', [ + transition(':enter', [ + style({ opacity: 0 }), + animate('300ms ease-out', style({ opacity: 1 })) + ]), + transition(':leave', [ + animate('200ms ease-in', style({ opacity: 0 })) + ]) + ]), + trigger('cardAnimation', [ + transition(':enter', [ + style({ opacity: 0, transform: 'scale(0.8)' }), + animate('350ms ease-out', style({ opacity: 1, transform: 'scale(1)' })) + ]) + ]) + ] +}) +export class GameResultComponent { + @Input() gameState: string = ''; + @Input() amount: number = 0; + @Input() set show(value: boolean) { + this.visible = value; + } + + visible = false; + + get isWin(): boolean { + return this.gameState === 'PLAYER_WON'; + } + + get isLoss(): boolean { + return this.gameState === 'PLAYER_LOST'; + } + + get isDraw(): boolean { + return this.gameState === 'DRAW'; + } + + getResultTitle(): string { + if (this.isWin) return 'Gewonnen!'; + if (this.isLoss) return 'Verloren!'; + if (this.isDraw) return 'Unentschieden!'; + return ''; + } + + getResultMessage(): string { + if (this.isWin) return 'Glückwunsch! Du hast diese Runde gewonnen.'; + if (this.isLoss) return 'Schade! Du hast diese Runde verloren.'; + if (this.isDraw) return 'Diese Runde endet unentschieden. Dein Einsatz wurde zurückgegeben.'; + return ''; + } + + getResultClass(): string { + if (this.isWin) return 'text-emerald'; + if (this.isLoss) return 'text-accent-red'; + if (this.isDraw) return 'text-yellow-400'; + return ''; + } +} diff --git a/frontend/src/app/feature/game/blackjack/services/blackjack.service.ts b/frontend/src/app/feature/game/blackjack/services/blackjack.service.ts index 86ab7cf..f0ce54e 100644 --- a/frontend/src/app/feature/game/blackjack/services/blackjack.service.ts +++ b/frontend/src/app/feature/game/blackjack/services/blackjack.service.ts @@ -41,4 +41,15 @@ export class BlackjackService { }) ); } + + getGame(gameId: number): Observable { + return this.http + .get(`/backend/blackjack/${gameId}`, { responseType: 'json' }) + .pipe( + catchError((error) => { + console.error('Get game error:', error); + throw error; + }) + ); + } }