Merge branch 'main' into feature/authentik
Some checks failed
CI / Get Changed Files (pull_request) Successful in 6s
CI / prettier (pull_request) Failing after 22s
CI / Checkstyle Main (pull_request) Failing after 35s
CI / eslint (pull_request) Failing after 1m41s
CI / test-build (pull_request) Successful in 1m48s

This commit is contained in:
Jan K9f 2025-04-02 16:00:01 +02:00
commit d7fe0e3965
Signed by: jank
GPG key ID: B9F475106B20F144
56 changed files with 2782 additions and 602 deletions

View file

@ -2,7 +2,7 @@
An online gaming platform offering various casino-style games with virtual currency support. This project features a modern tech stack with Angular frontend, Spring Boot backend, and complete user authentication.
Please refer to our [Style Guide](/frontend/README.md#style-guide) for design guidelines and component standards.
Please refer to our [Style Guide](https://git.kjan.de/SZUT/casino/wiki/Frontend#design-system) for design guidelines and component standards.
## Features
@ -67,6 +67,11 @@ cd backend
./gradlew bootRun
```
You may optionally install [watchexec](https://github.com/watchexec/watchexec?tab=readme-ov-file) and use this command to autorecompile the backend on file changes:
```bash
watchexec -r -e java ./gradlew :bootRun
```
The backend will be available at:
- API endpoint: http://localhost:8080
- Swagger documentation: http://localhost:8080/swagger
@ -164,4 +169,4 @@ References:
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

View file

@ -1,6 +1,6 @@
plugins {
java
id("org.springframework.boot") version "3.4.3"
id("org.springframework.boot") version "3.4.4"
id("io.spring.dependency-management") version "1.1.7"
id("checkstyle")
}
@ -39,7 +39,7 @@ repositories {
}
dependencies {
implementation("com.stripe:stripe-java:20.136.0")
implementation("com.stripe:stripe-java:29.0.0")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
compileOnly("org.projectlombok:lombok")
@ -47,10 +47,10 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
implementation("org.springframework.security:spring-security-oauth2-jose")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.4.4")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.4")
runtimeOnly("org.postgresql:postgresql")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.5")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6")
}
tasks.withType<Test> {

View file

@ -0,0 +1,20 @@
POST http://localhost:8080/blackjack/start
Authorization: Bearer {{token}}
Content-Type: application/json
{
"betAmount": 1.01
}
###
POST http://localhost:8080/blackjack/54/hit
Authorization: Bearer {{token}}
###
POST http://localhost:8080/blackjack/202/stand
Authorization: Bearer {{token}}
###
GET http://localhost:8080/blackjack/202
Authorization: Bearer {{token}}

View file

@ -1,6 +1,6 @@
POST http://localhost:9090/realms/LF12/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=password&client_id=lf12&username=lf12_test_user&password=secret
grant_type=password&client_id=lf12&username=lf12_test_user&password=secret&scope=openid
> {% client.global.set("token", response.body.access_token); %}

View file

@ -0,0 +1,9 @@
GET http://localhost:8080/lootboxes
Authorization: Bearer {{token}}
Content-Type: application/json
###
POST http://localhost:8080/lootboxes/2
Authorization: Bearer {{token}}
Content-Type: application/json

View file

@ -1,10 +1,20 @@
package de.szut.casino;
import de.szut.casino.lootboxes.LootBoxEntity;
import de.szut.casino.lootboxes.LootBoxRepository;
import de.szut.casino.lootboxes.RewardEntity;
import de.szut.casino.lootboxes.RewardRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@SpringBootApplication
public class CasinoApplication {
@ -16,4 +26,65 @@ public class CasinoApplication {
public static RestTemplate restTemplate() {
return new RestTemplate();
}
@Bean
public CommandLineRunner initData(LootBoxRepository lootBoxRepository, RewardRepository rewardRepository) {
return _ -> {
if (lootBoxRepository.count() == 0) {
LootBoxEntity basicLootBox = new LootBoxEntity();
basicLootBox.setName("Basic LootBox");
basicLootBox.setPrice(new BigDecimal("2"));
basicLootBox.setRewards(new ArrayList<>()); // Initialize the list
LootBoxEntity premiumLootBox = new LootBoxEntity();
premiumLootBox.setName("Premium LootBox");
premiumLootBox.setPrice(new BigDecimal("5"));
premiumLootBox.setRewards(new ArrayList<>()); // Initialize the list
lootBoxRepository.saveAll(Arrays.asList(basicLootBox, premiumLootBox));
RewardEntity commonReward = new RewardEntity();
commonReward.setValue(new BigDecimal("0.50"));
commonReward.setProbability(new BigDecimal("0.7"));
RewardEntity rareReward = new RewardEntity();
rareReward.setValue(new BigDecimal("2.00"));
rareReward.setProbability(new BigDecimal("0.25"));
RewardEntity epicReward = new RewardEntity();
epicReward.setValue(new BigDecimal("5.00"));
epicReward.setProbability(new BigDecimal("0.5"));
RewardEntity premiumCommon = new RewardEntity();
premiumCommon.setValue(new BigDecimal("2.00"));
premiumCommon.setProbability(new BigDecimal("0.6"));
RewardEntity premiumRare = new RewardEntity();
premiumRare.setValue(new BigDecimal("5.00"));
premiumRare.setProbability(new BigDecimal("0.3"));
RewardEntity legendaryReward = new RewardEntity();
legendaryReward.setValue(new BigDecimal("15.00"));
legendaryReward.setProbability(new BigDecimal("0.10"));
rewardRepository.saveAll(Arrays.asList(
commonReward, rareReward, epicReward,
premiumCommon, premiumRare, legendaryReward
));
basicLootBox.getRewards().add(commonReward);
basicLootBox.getRewards().add(premiumRare);
premiumLootBox.getRewards().add(premiumCommon);
premiumLootBox.getRewards().add(premiumRare);
premiumLootBox.getRewards().add(legendaryReward);
lootBoxRepository.saveAll(Arrays.asList(basicLootBox, premiumLootBox));
System.out.println("Initial LootBoxes and rewards created successfully");
} else {
System.out.println("LootBoxes already exist, skipping initialization");
}
};
}
}

View file

@ -0,0 +1,140 @@
package de.szut.casino.blackjack;
import de.szut.casino.blackjack.dto.CreateBlackJackGameDto;
import de.szut.casino.user.UserEntity;
import de.szut.casino.user.UserService;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@Slf4j
@RestController
public class BlackJackGameController {
private final UserService userService;
private final BlackJackService blackJackService;
public BlackJackGameController(UserService userService, BlackJackService blackJackService) {
this.blackJackService = blackJackService;
this.userService = userService;
}
@GetMapping("/blackjack/{id}")
public ResponseEntity<Object> getGame(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<UserEntity> 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<Object> hit(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<UserEntity> 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(blackJackService.hit(game));
}
@PostMapping("/blackjack/{id}/stand")
public ResponseEntity<Object> stand(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<UserEntity> 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(blackJackService.stand(game));
}
@PostMapping("/blackjack/{id}/doubleDown")
public ResponseEntity<Object> doubleDown(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<UserEntity> 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(blackJackService.doubleDown(game));
}
@PostMapping("/blackjack/{id}/split")
public ResponseEntity<Object> split(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<UserEntity> 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(blackJackService.split(game));
}
@PostMapping("/blackjack/start")
public ResponseEntity<Object> createBlackJackGame(@RequestBody @Valid CreateBlackJackGameDto createBlackJackGameDto, @RequestHeader("Authorization") String token) {
Optional<UserEntity> optionalUser = userService.getCurrentUser(token);
if (optionalUser.isEmpty()) {
return ResponseEntity.notFound().build();
}
UserEntity user = optionalUser.get();
BigDecimal balance = user.getBalance();
BigDecimal betAmount = createBlackJackGameDto.getBetAmount();
if (betAmount.compareTo(BigDecimal.ZERO) <= 0) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("error", "Invalid bet amount");
return ResponseEntity.badRequest().body(errorResponse);
}
if (betAmount.compareTo(balance) > 0) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("error", "Insufficient funds");
return ResponseEntity.badRequest().body(errorResponse);
}
return ResponseEntity.ok(blackJackService.createBlackJackGame(user, betAmount));
}
}

View file

@ -0,0 +1,65 @@
package de.szut.casino.blackjack;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import de.szut.casino.user.UserEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.SQLRestriction;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class BlackJackGameEntity {
@Id
@GeneratedValue
private Long id;
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
@JsonIgnore
private UserEntity user;
public Long getUserId() {
return user != null ? user.getId() : null;
}
@Enumerated(EnumType.STRING)
private BlackJackState state;
private BigDecimal bet;
@OneToMany(mappedBy = "game", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonIgnore
@SQLRestriction("card_type = 'DECK'")
private List<CardEntity> deck = new ArrayList<>();
@OneToMany(mappedBy = "game", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonManagedReference
@SQLRestriction("card_type = 'PLAYER'")
private List<CardEntity> playerCards = new ArrayList<>();
@OneToMany(mappedBy = "game", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonManagedReference
@SQLRestriction("card_type = 'DEALER'")
private List<CardEntity> dealerCards = new ArrayList<>();
@OneToMany(mappedBy = "game", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonManagedReference
@SQLRestriction("card_type = 'PLAYER_SPLIT'")
private List<CardEntity> playerSplitCards = new ArrayList<>();
@Column(name = "split_bet")
private BigDecimal splitBet;
@Column(name = "is_split")
private boolean isSplit;
}

View file

@ -0,0 +1,12 @@
package de.szut.casino.blackjack;
import de.szut.casino.user.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public interface BlackJackGameRepository extends JpaRepository<BlackJackGameEntity, Long> {
}

View file

@ -0,0 +1,310 @@
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;
import java.util.Optional;
import java.util.Random;
@Service
public class BlackJackService {
private final BlackJackGameRepository blackJackGameRepository;
private final UserRepository userRepository;
private final Random random = new Random();
public BlackJackService(BlackJackGameRepository blackJackGameRepository, UserRepository userRepository) {
this.blackJackGameRepository = blackJackGameRepository;
this.userRepository = userRepository;
}
public BlackJackGameEntity getBlackJackGame(Long id) {
return blackJackGameRepository.findById(id).orElse(null);
}
@Transactional
public BlackJackGameEntity createBlackJackGame(UserEntity user, BigDecimal betAmount) {
BlackJackGameEntity game = new BlackJackGameEntity();
game.setUser(user);
game.setBet(betAmount);
initializeDeck(game);
dealInitialCards(game);
game.setState(getState(game));
deductBetFromBalance(user, betAmount);
return blackJackGameRepository.save(game);
}
@Transactional
public BlackJackGameEntity hit(BlackJackGameEntity game) {
if (game.getState() != BlackJackState.IN_PROGRESS) {
return game;
}
dealCardToPlayer(game);
updateGameStateAndBalance(game);
return blackJackGameRepository.save(game);
}
@Transactional
public BlackJackGameEntity stand(BlackJackGameEntity game) {
if (game.getState() != BlackJackState.IN_PROGRESS) {
return game;
}
dealCardsToDealerUntilMinimumScore(game);
determineWinnerAndUpdateBalance(game);
return blackJackGameRepository.save(game);
}
@Transactional
public BlackJackGameEntity doubleDown(BlackJackGameEntity game) {
if (game.getState() != BlackJackState.IN_PROGRESS || game.getPlayerCards().size() != 2) {
return game;
}
UserEntity user = getUserWithFreshData(game.getUser());
BigDecimal additionalBet = game.getBet();
deductBetFromBalance(user, additionalBet);
game.setBet(game.getBet().add(additionalBet));
dealCardToPlayer(game);
updateGameStateAndBalance(game);
if (game.getState() == BlackJackState.IN_PROGRESS) {
return stand(game);
}
return game;
}
@Transactional
public BlackJackGameEntity split(BlackJackGameEntity game) {
if (game.getState() != BlackJackState.IN_PROGRESS ||
game.getPlayerCards().size() != 2 ||
game.isSplit() ||
!game.getPlayerCards().get(0).getRank().equals(game.getPlayerCards().get(1).getRank())) {
return game;
}
UserEntity user = getUserWithFreshData(game.getUser());
BigDecimal splitBet = game.getBet();
if (user.getBalance().compareTo(splitBet) < 0) {
return game;
}
deductBetFromBalance(user, splitBet);
game.setSplitBet(splitBet);
game.setSplit(true);
CardEntity card = game.getPlayerCards().remove(1);
card.setCardType(CardType.PLAYER_SPLIT);
game.getPlayerSplitCards().add(card);
dealCardToPlayer(game);
dealCardToSplitHand(game);
return blackJackGameRepository.save(game);
}
private BlackJackGameEntity refreshGameState(BlackJackGameEntity game) {
return blackJackGameRepository.findById(game.getId()).orElse(game);
}
private UserEntity getUserWithFreshData(UserEntity user) {
return userRepository.findById(user.getId()).orElse(user);
}
private void dealInitialCards(BlackJackGameEntity game) {
for (int i = 0; i < 2; i++) {
dealCardToPlayer(game);
}
dealCardToDealer(game);
}
private void dealCardToPlayer(BlackJackGameEntity game) {
CardEntity card = drawCardFromDeck(game);
card.setCardType(CardType.PLAYER);
game.getPlayerCards().add(card);
}
private void dealCardToDealer(BlackJackGameEntity game) {
CardEntity card = drawCardFromDeck(game);
card.setCardType(CardType.DEALER);
game.getDealerCards().add(card);
}
private void dealCardsToDealerUntilMinimumScore(BlackJackGameEntity game) {
while (calculateHandValue(game.getDealerCards()) < 17) {
dealCardToDealer(game);
}
}
private void dealCardToSplitHand(BlackJackGameEntity game) {
CardEntity card = drawCardFromDeck(game);
card.setCardType(CardType.PLAYER_SPLIT);
game.getPlayerSplitCards().add(card);
}
private void updateGameStateAndBalance(BlackJackGameEntity game) {
if (game.isSplit()) {
int mainHandValue = calculateHandValue(game.getPlayerCards());
int splitHandValue = calculateHandValue(game.getPlayerSplitCards());
if (mainHandValue > 21 && splitHandValue > 21) {
game.setState(BlackJackState.PLAYER_LOST);
updateUserBalance(game, false);
} else if (mainHandValue <= 21 && splitHandValue <= 21) {
game.setState(BlackJackState.IN_PROGRESS);
} else {
game.setState(BlackJackState.IN_PROGRESS);
}
} else {
game.setState(getState(game));
if (game.getState() == BlackJackState.PLAYER_WON) {
updateUserBalance(game, true);
} else if (game.getState() == BlackJackState.PLAYER_LOST) {
updateUserBalance(game, false);
}
}
}
private void determineWinnerAndUpdateBalance(BlackJackGameEntity game) {
int playerValue = calculateHandValue(game.getPlayerCards());
int dealerValue = calculateHandValue(game.getDealerCards());
if (dealerValue > 21 || playerValue > dealerValue) {
game.setState(BlackJackState.PLAYER_WON);
updateUserBalance(game, true);
} else if (playerValue < dealerValue) {
game.setState(BlackJackState.PLAYER_LOST);
updateUserBalance(game, false);
} else {
game.setState(BlackJackState.DRAW);
updateUserBalance(game, false);
}
}
private void deductBetFromBalance(UserEntity user, BigDecimal betAmount) {
user.setBalance(user.getBalance().subtract(betAmount));
userRepository.save(user);
}
@Transactional
private void updateUserBalance(BlackJackGameEntity game, boolean isWin) {
UserEntity user = getUserWithFreshData(game.getUser());
BigDecimal totalBet = game.getBet();
BigDecimal balance = user.getBalance();
if (game.isSplit()) {
totalBet = totalBet.add(game.getSplitBet());
if (isWin) {
int mainHandValue = calculateHandValue(game.getPlayerCards());
int splitHandValue = calculateHandValue(game.getPlayerSplitCards());
int dealerValue = calculateHandValue(game.getDealerCards());
if (mainHandValue <= 21 && (dealerValue > 21 || mainHandValue > dealerValue)) {
balance = balance.add(game.getBet().multiply(BigDecimal.valueOf(2)));
} else if (mainHandValue == dealerValue) {
balance = balance.add(game.getBet());
}
if (splitHandValue <= 21 && (dealerValue > 21 || splitHandValue > dealerValue)) {
balance = balance.add(game.getSplitBet().multiply(BigDecimal.valueOf(2)));
} else if (splitHandValue == dealerValue) {
balance = balance.add(game.getSplitBet());
}
} else if (game.getState() == BlackJackState.DRAW) {
balance = balance.add(totalBet);
}
} else {
if (isWin) {
balance = balance.add(totalBet.multiply(BigDecimal.valueOf(2)));
} else if (game.getState() == BlackJackState.DRAW) {
balance = balance.add(totalBet);
}
}
user.setBalance(balance);
userRepository.save(user);
}
private void initializeDeck(BlackJackGameEntity game) {
for (Suit suit : Suit.values()) {
for (Rank rank : Rank.values()) {
CardEntity card = new CardEntity();
card.setGame(game);
card.setSuit(suit);
card.setRank(rank);
card.setCardType(CardType.DECK);
game.getDeck().add(card);
}
}
java.util.Collections.shuffle(game.getDeck(), random);
}
private CardEntity drawCardFromDeck(BlackJackGameEntity game) {
if (game.getDeck().isEmpty()) {
throw new IllegalStateException("Deck is empty");
}
return game.getDeck().removeFirst();
}
private BlackJackState getState(BlackJackGameEntity game) {
int playerHandValue = calculateHandValue(game.getPlayerCards());
if (playerHandValue == 21) {
CardEntity hole = drawCardFromDeck(game);
hole.setCardType(CardType.DEALER);
game.getDealerCards().add(hole);
int dealerHandValue = calculateHandValue(game.getDealerCards());
if (dealerHandValue == 21) {
return BlackJackState.DRAW;
} else {
BigDecimal blackjackWinnings = game.getBet().multiply(new BigDecimal("1.5"));
UserEntity user = getUserWithFreshData(game.getUser());
user.setBalance(user.getBalance().add(blackjackWinnings));
return BlackJackState.PLAYER_BLACKJACK;
}
} else if (playerHandValue > 21) {
return BlackJackState.PLAYER_LOST;
}
return BlackJackState.IN_PROGRESS;
}
private int calculateHandValue(List<CardEntity> hand) {
int sum = 0;
int aceCount = 0;
for (CardEntity card : hand) {
sum += card.getRank().getValue();
if (card.getRank() == Rank.ACE) {
aceCount++;
}
}
while (sum > 21 && aceCount > 0) {
sum -= 10;
aceCount--;
}
return sum;
}
}

View file

@ -0,0 +1,9 @@
package de.szut.casino.blackjack;
public enum BlackJackState {
IN_PROGRESS,
PLAYER_BLACKJACK,
PLAYER_LOST,
PLAYER_WON,
DRAW,
}

View file

@ -0,0 +1,40 @@
package de.szut.casino.blackjack;
import com.fasterxml.jackson.annotation.JsonBackReference;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class CardEntity {
@Id
@GeneratedValue
@JsonIgnore
private Long id;
@ManyToOne
@JoinColumn(name = "game_id", nullable = false)
@JsonBackReference
private BlackJackGameEntity game;
@Enumerated(EnumType.STRING)
private Suit suit;
@Enumerated(EnumType.STRING)
private Rank rank;
@Enumerated(EnumType.STRING)
@JsonIgnore
private CardType cardType;
}
enum CardType {
DECK, PLAYER, DEALER, PLAYER_SPLIT
}

View file

@ -0,0 +1,31 @@
package de.szut.casino.blackjack;
import lombok.Getter;
@Getter
public enum Rank {
TWO("2", "Two", 2),
THREE("3", "Three", 3),
FOUR("4", "Four", 4),
FIVE("5", "Five", 5),
SIX("6", "Six", 6),
SEVEN("7", "Seven", 7),
EIGHT("8", "Eight", 8),
NINE("9", "Nine", 9),
TEN("10", "Ten", 10),
JACK("J", "Jack", 10),
QUEEN("Q", "Queen", 10),
KING("K", "King", 10),
ACE("A", "Ace", 11);
private final String symbol;
private final String displayName;
private final int value;
Rank(String symbol, String displayName, int value) {
this.symbol = symbol;
this.displayName = displayName;
this.value = value;
}
}

View file

@ -0,0 +1,20 @@
package de.szut.casino.blackjack;
import lombok.Getter;
@Getter
public enum Suit {
HEARTS("H", "Hearts"),
DIAMONDS("D", "Diamonds"),
CLUBS("C", "Clubs"),
SPADES("S", "Spades");
private final String symbol;
private final String displayName;
Suit(String symbol, String displayName) {
this.symbol = symbol;
this.displayName = displayName;
}
}

View file

@ -0,0 +1,16 @@
package de.szut.casino.blackjack.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class CreateBlackJackGameDto {
private BigDecimal betAmount;
}

View file

@ -52,10 +52,14 @@ public class DepositController {
SessionCreateParams params = SessionCreateParams.builder()
.addLineItem(SessionCreateParams.LineItem.builder()
.setAmount((long) amountDto.getAmount() * 100)
.setCurrency("EUR")
.setPriceData(SessionCreateParams.LineItem.PriceData.builder()
.setCurrency("EUR")
.setUnitAmount((long) amountDto.getAmount() * 100)
.setProductData(SessionCreateParams.LineItem.PriceData.ProductData.builder()
.setName("Einzahlung")
.build())
.build())
.setQuantity(1L)
.setName("Einzahlung")
.build())
.setSuccessUrl(frontendHost+"/home?success=true")
.setCancelUrl(frontendHost+"/home?success=false")

View file

@ -40,7 +40,7 @@ public class TransactionService {
.build();
Session checkoutSession = Session.retrieve(sessionID, params, null);
if (!Objects.equals(checkoutSession.getPaymentStatus(), "paid")) {
if (!"paid".equals(checkoutSession.getPaymentStatus())) {
return;
}
@ -53,10 +53,12 @@ public class TransactionService {
transaction.setStatus(TransactionStatus.SUCCEEDED);
UserEntity user = transaction.getUser();
user.addBalance(checkoutSession.getAmountTotal());
Long amountTotal = checkoutSession.getAmountTotal();
if (amountTotal != null) {
user.addBalance(amountTotal);
}
userRepository.save(user);
transactionRepository.save(transaction);
}
}

View file

@ -51,12 +51,18 @@ public class WebhookController {
switch (event.getType()) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded":
Session session = (Session) event.getData().getObject();
this.transactionService.fulfillCheckout(session.getId());
EventDataObjectDeserializer dataObjectDeserializer = event.getDataObjectDeserializer();
if (dataObjectDeserializer.getObject().isPresent()) {
Session session = (Session) dataObjectDeserializer.getObject().get();
this.transactionService.fulfillCheckout(session.getId());
} else {
logger.error("Failed to deserialize webhook event data");
}
break;
default:
// No action needed for other event types
break;
}
return ResponseEntity.ok().body(null);

View file

@ -0,0 +1,58 @@
package de.szut.casino.lootboxes;
import de.szut.casino.user.UserEntity;
import de.szut.casino.user.UserRepository;
import de.szut.casino.user.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
public class LootBoxController {
private final LootBoxRepository lootBoxRepository;
private final UserService userService;
private final LootBoxService lootBoxService;
public LootBoxController(LootBoxRepository lootBoxRepository, UserRepository userRepository, UserService userService, LootBoxService lootBoxService) {
this.lootBoxRepository = lootBoxRepository;
this.userService = userService;
this.lootBoxService = lootBoxService;
}
@GetMapping("/lootboxes")
public List<LootBoxEntity> getAllLootBoxes() {
return lootBoxRepository.findAll();
}
@PostMapping("/lootboxes/{id}")
public ResponseEntity<Object> purchaseLootBox(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<LootBoxEntity> optionalLootBox = lootBoxRepository.findById(id);
if (optionalLootBox.isEmpty()) {
return ResponseEntity.notFound().build();
}
LootBoxEntity lootBox = optionalLootBox.get();
Optional<UserEntity> optionalUser = userService.getCurrentUser(token);
if (optionalUser.isEmpty()) {
return ResponseEntity.notFound().build();
}
UserEntity user = optionalUser.get();
if (lootBoxService.hasSufficientBalance(user, lootBox.getPrice())) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("error", "Insufficient balance");
return ResponseEntity.badRequest().body(errorResponse);
}
RewardEntity reward = lootBoxService.determineReward(lootBox);
lootBoxService.handleBalance(user, lootBox, reward);
return ResponseEntity.ok(reward);
}
}

View file

@ -0,0 +1,40 @@
package de.szut.casino.lootboxes;
import com.fasterxml.jackson.annotation.JsonBackReference;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import de.szut.casino.blackjack.CardEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.SQLRestriction;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class LootBoxEntity {
@Id
@GeneratedValue
private Long id;
private String name;
@Column(precision = 19, scale = 2)
private BigDecimal price;
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "lootbox_reward",
joinColumns = @JoinColumn(name = "lootbox_id"),
inverseJoinColumns = @JoinColumn(name = "reward_id")
)
private List<RewardEntity> rewards = new ArrayList<>();
}

View file

@ -0,0 +1,8 @@
package de.szut.casino.lootboxes;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
@Service
public interface LootBoxRepository extends JpaRepository<LootBoxEntity, Long> {
}

View file

@ -0,0 +1,40 @@
package de.szut.casino.lootboxes;
import de.szut.casino.user.UserEntity;
import de.szut.casino.user.UserRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class LootBoxService {
private final UserRepository userRepository;
public LootBoxService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public boolean hasSufficientBalance(UserEntity user, BigDecimal price) {
return user.getBalance().compareTo(price) < 0;
}
public RewardEntity determineReward(LootBoxEntity lootBox) {
double randomValue = Math.random();
BigDecimal cumulativeProbability = BigDecimal.ZERO;
for (RewardEntity reward : lootBox.getRewards()) {
cumulativeProbability = cumulativeProbability.add(reward.getProbability());
if (randomValue <= cumulativeProbability.doubleValue()) {
return reward;
}
}
return lootBox.getRewards().getLast();
}
public void handleBalance(UserEntity user, LootBoxEntity lootBox, RewardEntity reward) {
user.setBalance(user.getBalance().subtract(lootBox.getPrice()));
user.setBalance(user.getBalance().add(reward.getValue()));
userRepository.save(user);
}
}

View file

@ -0,0 +1,30 @@
package de.szut.casino.lootboxes;
import com.fasterxml.jackson.annotation.JsonBackReference;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@Getter
@Setter
@Entity
public class RewardEntity {
@Id
@GeneratedValue
private Long id;
@Column(precision = 19, scale = 2)
private BigDecimal value;
@Column(precision = 5, scale = 2)
private BigDecimal probability;
@ManyToMany(mappedBy = "rewards")
@JsonBackReference
private List<LootBoxEntity> lootBoxes = new ArrayList<>();
}

View file

@ -0,0 +1,8 @@
package de.szut.casino.lootboxes;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
@Service
public interface RewardRepository extends JpaRepository<RewardEntity, Long> {
}

View file

@ -36,7 +36,7 @@ public class UserController {
@GetMapping("/user")
public ResponseEntity<GetUserDto> getCurrentUser(@RequestHeader("Authorization") String token) {
GetUserDto userData = userService.getCurrentUser(token);
GetUserDto userData = userService.getCurrentUserAsDto(token);
if (userData == null) {
return ResponseEntity.notFound().build();

View file

@ -37,7 +37,7 @@ public class UserService {
return user.map(userEntity -> mappingService.mapToGetUserDto(userEntity)).orElse(null);
}
public GetUserDto getCurrentUser(String token) {
public GetUserDto getCurrentUserAsDto(String token) {
KeycloakUserDto userData = getAuthentikUserInfo(token);
if (userData == null) {
@ -48,6 +48,15 @@ public class UserService {
return user.map(userEntity -> mappingService.mapToGetUserDto(userEntity)).orElse(null);
}
public Optional<UserEntity> getCurrentUser(String token) {
KeycloakUserDto userData = getKeycloakUserInfo(token);
if (userData == null) {
return Optional.empty();
}
return this.userRepository.findOneByKeycloakId(userData.getSub());
}
private KeycloakUserDto getAuthentikUserInfo(String token) {
try {
HttpHeaders headers = new HttpHeaders();

View file

@ -2,7 +2,7 @@ spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:5432/postgresdb
spring.datasource.username=postgres_user
spring.datasource.password=postgres_pass
server.port=8080
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.hibernate.ddl-auto=update
stripe.secret.key=${STRIPE_SECRET_KEY:sk_test_51QrePYIvCfqz7ANgqam8rEwWcMeKiLOof3j6SCMgu2sl4sESP45DJxca16mWcYo1sQaiBv32CMR6Z4AAAGQPCJo300ubuZKO8I}
stripe.webhook.secret=whsec_746b6a488665f6057118bdb4a2b32f4916f16c277109eeaed5e8f8e8b81b8c15
app.frontend-host=http://localhost:4200

File diff suppressed because it is too large Load diff

View file

@ -9,38 +9,42 @@
"test": "bunx @angular/cli test",
"format": "prettier --write \"src/**/*.{ts,html,css,scss}\"",
"format:check": "prettier --check \"src/**/*.{ts,html,css,scss}\"",
"lint": "ng lint"
"lint": "bunx @angular/cli lint"
},
"private": true,
"dependencies": {
"@angular/animations": "^18.2.0",
"@angular/cdk": "~18.2.14",
"@angular/common": "^18.2.0",
"@angular/compiler": "^18.2.0",
"@angular/core": "^18.2.0",
"@angular/forms": "^18.2.0",
"@angular/platform-browser": "^18.2.0",
"@angular/platform-browser-dynamic": "^18.2.0",
"@angular/router": "^18.2.0",
"@angular/animations": "^19.0.0",
"@angular/cdk": "~19.2.0",
"@angular/common": "^19.0.0",
"@angular/compiler": "^19.2.4",
"@angular/core": "^19.0.0",
"@angular/forms": "^19.0.0",
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@fortawesome/angular-fontawesome": "^1.0.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@stripe/stripe-js": "^5.6.0",
"@stripe/stripe-js": "^7.0.0",
"@tailwindcss/postcss": "^4.0.3",
"ajv": "8.17.1",
"ajv-formats": "3.0.1",
"gsap": "^3.12.7",
"angular-oauth2-oidc": "^19.0.0",
"keycloak-angular": "^19.0.0",
"keycloak-js": "^26.0.0",
"postcss": "^8.5.1",
"rxjs": "~7.8.0",
"rxjs": "~7.8.2",
"tailwindcss": "^4.0.3",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.2.2",
"@angular/cli": "^18.2.2",
"@angular/compiler-cli": "^18.2.0",
"@angular-devkit/build-angular": "^19.0.0",
"@angular/cli": "^19.2.5",
"@angular/compiler-cli": "^19.0.0",
"@types/jasmine": "~5.1.0",
"angular-eslint": "19.2.1",
"angular-eslint": "19.3.0",
"eslint": "^9.20.0",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
@ -49,7 +53,7 @@
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"prettier": "^3.4.2",
"typescript": "~5.5.0",
"typescript-eslint": "8.26.1"
"typescript": "~5.8.0",
"typescript-eslint": "8.29.0"
}
}

View file

@ -16,4 +16,9 @@ export const routes: Routes = [
loadComponent: () => import('./feature/home/home.component'),
canActivate: [authGuard],
},
{
path: 'game/blackjack',
loadComponent: () => import('./feature/game/blackjack/blackjack.component'),
canActivate: [authGuard],
},
];

View file

@ -16,12 +16,12 @@ import {
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { loadStripe, Stripe } from '@stripe/stripe-js';
import { DepositService } from '../../service/deposit.service';
import { debounceTime } from 'rxjs';
import { environment } from '../../../environments/environment';
import { NgIf } from '@angular/common';
import { ModalAnimationService } from '../../shared/services/modal-animation.service';
import gsap from 'gsap';
import { DepositService } from '@service/deposit.service';
import { environment } from '@environments/environment';
import { ModalAnimationService } from '@shared/services/modal-animation.service';
@Component({
selector: 'app-deposit',

View file

@ -0,0 +1,59 @@
<app-navbar></app-navbar>
<div class="container mx-auto px-4 py-6 space-y-8">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div class="lg:col-span-3 space-y-6 flex flex-col gap-4">
<app-dealer-hand [cards]="dealerCards()"></app-dealer-hand>
<app-player-hand [cards]="playerCards()"></app-player-hand>
@if (isActionInProgress()) {
<div class="flex justify-center">
<div
class="card p-4 flex items-center gap-3 animate-pulse bg-deep-blue-light border border-deep-blue-light/50"
>
<div
class="w-5 h-5 rounded-full border-2 border-white border-t-transparent animate-spin"
></div>
<span>{{ currentAction() }}</span>
</div>
</div>
}
@if (gameInProgress()) {
<app-game-controls
[playerCards]="playerCards()"
[gameState]="gameState()"
[isActionInProgress]="isActionInProgress()"
(hit)="onHit()"
(stand)="onStand()"
(doubleDown)="onDoubleDown()"
(leave)="leaveGame()"
></app-game-controls>
}
</div>
<div class="lg:col-span-1 space-y-6">
<app-game-info
[balance]="balance()"
[currentBet]="currentBet()"
[gameInProgress]="gameInProgress()"
[isActionInProgress]="isActionInProgress()"
(newGame)="onNewGame($event)"
></app-game-info>
</div>
</div>
</div>
<app-game-result
[gameState]="gameState()"
[amount]="currentBet()"
[balance]="balance()"
[show]="showGameResult()"
(gameResultClosed)="onCloseGameResult()"
></app-game-result>
<app-debt-dialog
[amount]="debtAmount()"
[show]="showDebtDialog()"
(dialogClosed)="onCloseDebtDialog()"
></app-debt-dialog>

View file

@ -0,0 +1,231 @@
import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { PlayingCardComponent } from './components/playing-card/playing-card.component';
import { DealerHandComponent } from './components/dealer-hand/dealer-hand.component';
import { PlayerHandComponent } from './components/player-hand/player-hand.component';
import { GameControlsComponent } from './components/game-controls/game-controls.component';
import { GameInfoComponent } from './components/game-info/game-info.component';
import { Card, BlackjackGame } from '@blackjack/models/blackjack.model';
import { BlackjackService } from '@blackjack/services/blackjack.service';
import { HttpErrorResponse } from '@angular/common/http';
import { GameResultComponent } from '@blackjack/components/game-result/game-result.component';
import { GameState } from '@blackjack/enum/gameState';
import { NavbarComponent } from '@shared/components/navbar/navbar.component';
import { UserService } from '@service/user.service';
import { timer } from 'rxjs';
import { DebtDialogComponent } from '@shared/components/debt-dialog/debt-dialog.component';
@Component({
selector: 'app-blackjack',
standalone: true,
imports: [
CommonModule,
NavbarComponent,
PlayingCardComponent,
DealerHandComponent,
PlayerHandComponent,
GameControlsComponent,
GameInfoComponent,
GameResultComponent,
DebtDialogComponent,
],
templateUrl: './blackjack.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class BlackjackComponent implements OnInit {
private router = inject(Router);
private userService = inject(UserService);
private blackjackService = inject(BlackjackService);
dealerCards = signal<Card[]>([]);
playerCards = signal<Card[]>([]);
currentBet = signal(0);
balance = signal(0);
currentGameId = signal<number | undefined>(undefined);
gameInProgress = signal(false);
gameState = signal<GameState>(GameState.IN_PROGRESS);
showGameResult = signal(false);
isActionInProgress = signal(false);
currentAction = signal<string>('');
showDebtDialog = signal(false);
debtAmount = signal(0);
ngOnInit(): void {
this.userService.currentUser$.subscribe((user) => {
if (user) {
this.balance.set(user.balance);
}
});
}
private updateGameState(game: BlackjackGame) {
console.log('Game state update:', game);
this.currentGameId.set(game.id);
this.currentBet.set(game.bet);
this.gameInProgress.set(game.state === GameState.IN_PROGRESS);
this.gameState.set(game.state as GameState);
const isGameOver = game.state !== GameState.IN_PROGRESS;
this.dealerCards.set(
game.dealerCards.map((card, index) => ({
...card,
hidden: !isGameOver && index === 1 && game.state === GameState.IN_PROGRESS,
}))
);
this.playerCards.set(
game.playerCards.map((card) => ({
...card,
hidden: false,
}))
);
if (isGameOver) {
console.log('Game is over, state:', game.state);
this.userService.refreshCurrentUser();
timer(1500).subscribe(() => {
this.showGameResult.set(true);
console.log('Game result dialog shown after delay');
});
}
}
onNewGame(bet: number): void {
this.isActionInProgress.set(true);
this.currentAction.set('Spiel wird gestartet...');
this.blackjackService.startGame(bet).subscribe({
next: (game) => {
this.updateGameState(game);
this.userService.refreshCurrentUser();
this.isActionInProgress.set(false);
},
error: (error) => {
console.error('Failed to start game:', error);
this.isActionInProgress.set(false);
},
});
}
onHit(): void {
if (!this.currentGameId() || this.isActionInProgress()) return;
this.isActionInProgress.set(true);
this.currentAction.set('Karte wird gezogen...');
this.blackjackService.hit(this.currentGameId()!).subscribe({
next: (game) => {
this.updateGameState(game);
if (game.state !== 'IN_PROGRESS') {
this.userService.refreshCurrentUser();
}
this.isActionInProgress.set(false);
},
error: (error) => {
console.error('Failed to hit:', error);
this.handleGameError(error);
this.isActionInProgress.set(false);
},
});
}
onStand(): void {
if (!this.currentGameId() || this.isActionInProgress()) return;
if (this.gameState() !== GameState.IN_PROGRESS) {
console.log('Cannot stand: game is not in progress');
return;
}
this.isActionInProgress.set(true);
this.currentAction.set('Dealer zieht Karten...');
this.blackjackService.stand(this.currentGameId()!).subscribe({
next: (game) => {
this.updateGameState(game);
this.userService.refreshCurrentUser();
this.isActionInProgress.set(false);
},
error: (error) => {
console.error('Failed to stand:', error);
this.handleGameError(error);
this.isActionInProgress.set(false);
},
});
}
onDoubleDown(): void {
if (!this.currentGameId() || this.isActionInProgress()) return;
if (this.gameState() !== GameState.IN_PROGRESS || this.playerCards().length !== 2) {
console.log('Cannot double down: game is not in progress or more than 2 cards');
return;
}
this.isActionInProgress.set(true);
this.currentAction.set('Einsatz wird verdoppelt...');
this.blackjackService.doubleDown(this.currentGameId()!).subscribe({
next: (game) => {
this.updateGameState(game);
this.userService.getCurrentUser().subscribe((user) => {
if (user && user.balance < 0) {
this.debtAmount.set(Math.abs(user.balance));
this.showDebtDialog.set(true);
}
});
this.isActionInProgress.set(false);
},
error: (error) => {
console.error('Failed to double down:', error);
this.handleGameError(error);
this.isActionInProgress.set(false);
},
});
}
onCloseGameResult(): void {
console.log('Closing game result dialog');
this.showGameResult.set(false);
this.userService.refreshCurrentUser();
}
onCloseDebtDialog(): void {
this.showDebtDialog.set(false);
}
private handleGameError(error: HttpErrorResponse): void {
if (error instanceof HttpErrorResponse) {
if (error.status === 400 && error.error?.error === 'Invalid state') {
this.gameInProgress.set(false);
this.userService.refreshCurrentUser();
} else if (error.status === 500) {
console.log('Server error occurred. The game may have been updated in another session.');
this.gameInProgress.set(false);
this.userService.refreshCurrentUser();
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']);
}
}

View file

@ -0,0 +1,73 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Card } from '@blackjack/models/blackjack.model';
import { PlayingCardComponent } from '../playing-card/playing-card.component';
import { GameControlsService } from '@blackjack/services/game-controls.service';
@Component({
selector: 'app-dealer-hand',
standalone: true,
imports: [CommonModule, PlayingCardComponent],
template: `
<div class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="section-heading text-2xl">Dealer's Karten</h3>
<div class="flex items-center gap-2">
<div class="text-text-secondary">Punkte:</div>
<div class="text-xl font-bold text-accent-red">
{{ gameControlsService.calculateHandValue(cards) }}
</div>
</div>
</div>
<div class="card p-6 !bg-accent-red">
<div class="flex justify-center gap-4 min-h-[160px] p-4 border-2 border-red-400 rounded-lg">
@if (cards.length > 0) {
@for (card of cardsWithState; track card.id) {
<app-playing-card
[rank]="card.rank"
[suit]="card.suit"
[hidden]="card.hidden"
[isNew]="card.isNew"
></app-playing-card>
}
} @else {
<div class="flex items-center justify-center text-white/70 text-lg font-medium">
Warte auf Spielstart...
</div>
}
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DealerHandComponent implements OnChanges {
@Input() cards: Card[] = [];
cardsWithState: (Card & { isNew: boolean; id: string })[] = [];
private lastCardCount = 0;
constructor(protected gameControlsService: GameControlsService) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['cards']) {
this.updateCardsWithState();
}
}
private updateCardsWithState(): void {
const newCards = this.cards.length > this.lastCardCount;
this.cardsWithState = this.cards.map((card, index) => {
const isNew = newCards && index >= this.lastCardCount;
return {
...card,
isNew,
id: `${card.suit}-${card.rank}-${index}`,
};
});
this.lastCardCount = this.cards.length;
}
}

View file

@ -0,0 +1,100 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { GameState } from '@blackjack/enum/gameState';
import { Card } from '@blackjack/models/blackjack.model';
import { GameControlsService } from '@blackjack/services/game-controls.service';
@Component({
selector: 'app-game-controls',
standalone: true,
imports: [CommonModule],
template: `
<div class="flex flex-col gap-4">
<div class="flex justify-center text-lg mb-5">
<div class="card p-4">
<div class="text-emerald font-bold mb-1">
Deine Punkte: {{ gameControlsService.calculateHandValue(playerCards) }}
</div>
<div class="text-text-secondary">
Status:
<span [class]="gameControlsService.getStatusClass(gameState)">{{
gameControlsService.getStatusText(gameState)
}}</span>
</div>
</div>
</div>
<div class="flex justify-center gap-4">
<button
(click)="hit.emit()"
class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative"
[disabled]="gameState !== GameState.IN_PROGRESS || isActionInProgress"
[class.opacity-50]="isActionInProgress"
>
<span [class.invisible]="isActionInProgress">Ziehen</span>
@if (isActionInProgress) {
<div class="absolute inset-0 flex items-center justify-center">
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
</div>
}
</button>
<button
(click)="stand.emit()"
class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative"
[disabled]="gameState !== GameState.IN_PROGRESS || isActionInProgress"
[class.opacity-50]="isActionInProgress"
>
<span [class.invisible]="isActionInProgress">Halten</span>
@if (isActionInProgress) {
<div class="absolute inset-0 flex items-center justify-center">
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
</div>
}
</button>
<button
(click)="doubleDown.emit()"
class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative"
[disabled]="
gameState !== GameState.IN_PROGRESS || playerCards.length !== 2 || isActionInProgress
"
[class.opacity-50]="isActionInProgress"
>
<span [class.invisible]="isActionInProgress">Verdoppeln</span>
@if (isActionInProgress) {
<div class="absolute inset-0 flex items-center justify-center">
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
</div>
}
</button>
<button
(click)="leave.emit()"
class="bg-accent-red hover:bg-accent-red/80 px-8 py-4 rounded text-lg font-medium min-w-[120px] transition-all duration-300"
[disabled]="isActionInProgress"
[class.opacity-50]="isActionInProgress"
>
Abbrechen
</button>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GameControlsComponent {
@Input() playerCards: Card[] = [];
@Input() gameState: GameState = GameState.IN_PROGRESS;
@Input() isActionInProgress = false;
@Output() hit = new EventEmitter<void>();
@Output() stand = new EventEmitter<void>();
@Output() doubleDown = new EventEmitter<void>();
@Output() leave = new EventEmitter<void>();
protected readonly GameState = GameState;
constructor(protected gameControlsService: GameControlsService) {}
}

View file

@ -0,0 +1,149 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
Output,
SimpleChanges,
signal,
} from '@angular/core';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { BettingService } from '@blackjack/services/betting.service';
@Component({
selector: 'app-game-info',
standalone: true,
imports: [CommonModule, CurrencyPipe, ReactiveFormsModule],
template: `
<div class="card p-4">
<h3 class="section-heading text-xl mb-4">Spiel Informationen</h3>
<div class="space-y-4">
<div class="flex justify-between items-center">
<span class="text-text-secondary">Aktuelle Wette:</span>
<span [class]="currentBet > 0 ? 'text-accent-red' : 'text-text-secondary'">
{{ currentBet | currency: 'EUR' }}
</span>
</div>
@if (!gameInProgress) {
<div class="grid grid-cols-2 gap-2 mb-4">
<button
(click)="setBetAmount(0.1)"
class="button-primary py-2 text-sm"
[disabled]="gameInProgress"
>
10%
</button>
<button
(click)="setBetAmount(0.25)"
class="button-primary py-2 text-sm"
[disabled]="gameInProgress"
>
25%
</button>
<button
(click)="setBetAmount(0.5)"
class="button-primary py-2 text-sm"
[disabled]="gameInProgress"
>
50%
</button>
<button
(click)="setBetAmount(1)"
class="button-primary py-2 text-sm"
[disabled]="gameInProgress"
>
100%
</button>
</div>
}
<form [formGroup]="betForm" (ngSubmit)="onSubmit()" class="space-y-2">
<div class="space-y-1">
<label for="bet" class="text-sm text-text-secondary">Einsatz</label>
<input
type="number"
id="bet"
formControlName="bet"
class="w-full px-3 py-2 bg-deep-blue-light text-white rounded focus:outline-none focus:ring-2 focus:ring-emerald disabled:opacity-50"
[min]="1"
[max]="balance"
step="0.01"
[disabled]="gameInProgress || isActionInProgress"
[placeholder]="balance | currency: 'EUR'"
/>
@if (betForm.get('bet')?.errors?.['required'] && betForm.get('bet')?.touched) {
<span class="text-xs text-accent-red">Bitte geben Sie einen Einsatz ein</span>
}
@if (betForm.get('bet')?.errors?.['min'] && betForm.get('bet')?.touched) {
<span class="text-xs text-accent-red">Mindestens 1 setzen</span>
}
@if (betForm.get('bet')?.errors?.['max'] && betForm.get('bet')?.touched) {
<span class="text-xs text-accent-red">Nicht genügend Guthaben</span>
}
</div>
<button
type="submit"
class="button-primary w-full py-2 relative"
[disabled]="!betForm.valid || gameInProgress || isActionInProgress"
>
<span [class.invisible]="isActionInProgress">Neues Spiel</span>
@if (isActionInProgress) {
<div class="absolute inset-0 flex items-center justify-center">
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
</div>
}
</button>
</form>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GameInfoComponent implements OnChanges {
@Input() set balance(value: number) {
this._balance.set(value);
}
get balance() {
return this._balance();
}
private _balance = signal(0);
@Input() currentBet = 0;
@Input() gameInProgress = false;
@Input() isActionInProgress = false;
@Output() newGame = new EventEmitter<number>();
betForm: FormGroup;
constructor(private bettingService: BettingService) {
this.betForm = this.bettingService.createBetForm();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['balance']) {
this.bettingService.updateBetFormValidators(this.betForm, this.balance);
}
}
setBetAmount(percentage: number) {
const betAmount = this.bettingService.calculateBetAmount(this.balance, percentage);
if (this.bettingService.isValidBet(betAmount, this.balance)) {
this.betForm.patchValue({ bet: betAmount });
}
}
onSubmit() {
if (this.betForm.valid) {
const betAmount = parseFloat(this.betForm.value.bet);
if (this.bettingService.isValidBet(betAmount, this.balance)) {
this.newGame.emit(betAmount);
this.betForm.reset();
}
}
}
}

View file

@ -0,0 +1,127 @@
import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { animate, style, transition, trigger } from '@angular/animations';
import { GameState } from '../../enum/gameState';
@Component({
selector: 'app-game-result',
standalone: true,
imports: [CommonModule, CurrencyPipe],
template: `
<div *ngIf="visible" [@fadeInOut] class="modal-bg" style="z-index: 1000; position: fixed;">
<div class="modal-card" [@cardAnimation]>
<h2 class="modal-heading" [class]="getResultClass()">{{ getResultTitle() }}</h2>
<p class="py-2 text-text-secondary mb-4">{{ getResultMessage() }}</p>
<div
class="bg-deep-blue-light/50 rounded-lg p-5 mb-6 shadow-inner border border-deep-blue-light/30"
>
<div class="grid grid-cols-2 gap-4">
<div class="text-text-secondary">Einsatz:</div>
<div class="font-medium text-right">{{ amount | currency: 'EUR' }}</div>
<div class="text-text-secondary">
{{ isDraw ? 'Zurückgegeben:' : isWin ? 'Gewonnen:' : 'Verloren:' }}
</div>
<div
class="font-medium text-right"
[ngClass]="{
'text-emerald': isWin,
'text-accent-red': isLoss,
'text-yellow-400': isDraw,
}"
>
{{ isLoss ? '-' : '+' }}{{ isWin ? amount * 2 : (amount | currency: 'EUR') }}
<div *ngIf="isWin" class="text-xs text-text-secondary">
(Einsatz {{ amount | currency: 'EUR' }} × 2)
</div>
</div>
<div class="text-text-secondary border-t border-text-secondary/20 pt-3 font-medium">
Kontostand:
</div>
<div class="font-medium text-right border-t border-text-secondary/20 pt-3">
{{ balance | currency: 'EUR' }}
</div>
</div>
</div>
<button type="button" (click)="closeDialog()" class="button-primary w-full py-2">
Verstanden
</button>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('fadeInOut', [
transition(':enter', [
style({ opacity: 0 }),
animate('150ms ease-out', style({ opacity: 1 })),
]),
transition(':leave', [animate('150ms ease-in', style({ opacity: 0 }))]),
]),
trigger('cardAnimation', [
transition(':enter', [
style({ opacity: 0, transform: 'scale(0.95)' }),
animate('200ms ease-out', style({ opacity: 1, transform: 'scale(1)' })),
]),
]),
],
})
export class GameResultComponent {
@Input() gameState: GameState = GameState.IN_PROGRESS;
@Input() amount = 0;
@Input() balance = 0;
@Input() set show(value: boolean) {
console.log('GameResultComponent show input changed:', value, 'gameState:', this.gameState);
this.visible = value;
}
@Output() gameResultClosed = new EventEmitter<void>();
visible = false;
get isWin(): boolean {
return this.gameState === GameState.PLAYER_WON || this.gameState === GameState.PLAYER_BLACKJACK;
}
get isLoss(): boolean {
return this.gameState === GameState.PLAYER_LOST;
}
get isDraw(): boolean {
return this.gameState === GameState.DRAW;
}
getResultTitle(): string {
if (this.gameState === GameState.PLAYER_BLACKJACK) return 'Blackjack!';
if (this.isWin) return 'Gewonnen!';
if (this.isLoss) return 'Verloren!';
if (this.isDraw) return 'Unentschieden!';
return '';
}
getResultMessage(): string {
if (this.gameState === GameState.PLAYER_BLACKJACK)
return 'Glückwunsch! Du hast mit einem Blackjack gewonnen!';
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.gameState === GameState.PLAYER_BLACKJACK) return 'text-emerald font-bold';
if (this.isWin) return 'text-emerald';
if (this.isLoss) return 'text-accent-red';
if (this.isDraw) return 'text-yellow-400';
return '';
}
closeDialog(): void {
this.visible = false;
this.gameResultClosed.emit();
console.log('Dialog closed by user');
}
}

View file

@ -0,0 +1,75 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PlayingCardComponent } from '../playing-card/playing-card.component';
import { Card } from '@blackjack/models/blackjack.model';
import { GameControlsService } from '@blackjack/services/game-controls.service';
@Component({
selector: 'app-player-hand',
standalone: true,
imports: [CommonModule, PlayingCardComponent],
template: `
<div class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="section-heading text-2xl">Deine Karten</h3>
<div class="flex items-center gap-2">
<div class="text-text-secondary">Punkte:</div>
<div class="text-xl font-bold text-emerald">
{{ gameControlsService.calculateHandValue(cards) }}
</div>
</div>
</div>
<div class="card p-6 !bg-emerald">
<div
class="flex justify-center gap-4 min-h-[160px] p-4 border-2 border-emerald-400 rounded-lg"
>
@if (cards.length > 0) {
@for (card of cardsWithState; track card.id) {
<app-playing-card
[rank]="card.rank"
[suit]="card.suit"
[hidden]="card.hidden"
[isNew]="card.isNew"
></app-playing-card>
}
} @else {
<div class="flex items-center justify-center text-white/70 text-lg font-medium">
Platziere eine Wette um zu spielen...
</div>
}
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlayerHandComponent implements OnChanges {
@Input() cards: Card[] = [];
cardsWithState: (Card & { isNew: boolean; id: string })[] = [];
private lastCardCount = 0;
constructor(protected gameControlsService: GameControlsService) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['cards']) {
this.updateCardsWithState();
}
}
private updateCardsWithState(): void {
const newCards = this.cards.length > this.lastCardCount;
this.cardsWithState = this.cards.map((card, index) => {
const isNew = newCards && index >= this.lastCardCount;
return {
...card,
isNew,
id: `${card.suit}-${card.rank}-${index}`,
};
});
this.lastCardCount = this.cards.length;
}
}

View file

@ -0,0 +1,133 @@
import {
ChangeDetectionStrategy,
Component,
Input,
AfterViewInit,
ElementRef,
OnChanges,
SimpleChanges,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { gsap } from 'gsap';
import { Suit, suitSymbols } from '@blackjack/models/blackjack.model';
@Component({
selector: 'app-playing-card',
standalone: true,
imports: [CommonModule],
template: `
<div
#cardElement
class="w-24 h-36 rounded-lg p-2 relative flex flex-col justify-between shadow-lg card-element"
[class]="hidden ? 'bg-red-800' : 'bg-white'"
>
@if (!hidden) {
<span class="text-xl font-bold" [class]="isRedSuit ? 'text-accent-red' : 'text-black'">{{
getDisplayRank(rank)
}}</span>
}
@if (!hidden) {
<span
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-3xl"
[class]="isRedSuit ? 'text-accent-red' : 'text-black'"
>{{ getSuitSymbol(suit) }}</span
>
}
@if (!hidden) {
<span
class="text-xl font-bold self-end rotate-180"
[class]="isRedSuit ? 'text-accent-red' : 'text-black'"
>{{ getDisplayRank(rank) }}</span
>
}
</div>
`,
styles: [
`
.card-element {
transform-style: preserve-3d;
backface-visibility: hidden;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlayingCardComponent implements AfterViewInit, OnChanges {
@Input({ required: true }) rank!: string;
@Input({ required: true }) suit!: Suit;
@Input({ required: true }) hidden!: boolean;
@Input() isNew = false;
constructor(private elementRef: ElementRef) {}
get isRedSuit(): boolean {
return this.suit === 'HEARTS' || this.suit === 'DIAMONDS';
}
ngAfterViewInit(): void {
if (this.isNew) {
this.animateNewCard();
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['hidden'] && !changes['hidden'].firstChange) {
this.animateCardFlip();
}
}
private animateNewCard(): void {
const cardElement = this.elementRef.nativeElement.querySelector('.card-element');
gsap.fromTo(
cardElement,
{
y: -100,
opacity: 0,
rotation: -10,
scale: 0.7,
},
{
y: 0,
opacity: 1,
rotation: 0,
scale: 1,
duration: 0.5,
ease: 'power2.out',
}
);
}
private animateCardFlip(): void {
const cardElement = this.elementRef.nativeElement.querySelector('.card-element');
gsap.to(cardElement, {
rotationY: 180,
duration: 0.3,
onComplete: () => {
gsap.set(cardElement, { rotationY: 0 });
},
});
}
protected getSuitSymbol(suit: Suit): string {
return suitSymbols[suit];
}
protected getDisplayRank(rank: string): string {
const rankMap: Record<string, string> = {
TWO: '2',
THREE: '3',
FOUR: '4',
FIVE: '5',
SIX: '6',
SEVEN: '7',
EIGHT: '8',
NINE: '9',
TEN: '10',
JACK: 'J',
QUEEN: 'Q',
KING: 'K',
ACE: 'A',
};
return rankMap[rank] || rank;
}
}

View file

@ -0,0 +1,7 @@
export enum GameState {
PLAYER_WON = 'PLAYER_WON',
IN_PROGRESS = 'IN_PROGRESS',
PLAYER_LOST = 'PLAYER_LOST',
DRAW = 'DRAW',
PLAYER_BLACKJACK = 'PLAYER_BLACKJACK',
}

View file

@ -0,0 +1,23 @@
export type Suit = 'HEARTS' | 'DIAMONDS' | 'CLUBS' | 'SPADES';
export interface Card {
suit: Suit;
rank: string;
hidden: boolean;
}
export interface BlackjackGame {
id: number;
state: string;
bet: number;
playerCards: Card[];
dealerCards: Card[];
userId: number;
}
export const suitSymbols: Record<Suit, string> = {
HEARTS: '♥',
DIAMONDS: '♦',
CLUBS: '♣',
SPADES: '♠',
};

View file

@ -0,0 +1,5 @@
export interface Card {
value: string;
suit: string;
hidden: boolean;
}

View file

@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
@Injectable({
providedIn: 'root',
})
export class BettingService {
constructor(private fb: FormBuilder) {}
createBetForm(): FormGroup {
return this.fb.group({
bet: ['', [Validators.required, Validators.min(1)]],
});
}
updateBetFormValidators(form: FormGroup, balance: number): void {
form.reset();
form
.get('bet')
?.setValidators([Validators.required, Validators.min(1), Validators.max(balance)]);
form.get('bet')?.updateValueAndValidity();
}
calculateBetAmount(balance: number, percentage: number): number {
return Math.floor(balance * percentage * 100) / 100;
}
isValidBet(betAmount: number, balance: number): boolean {
return betAmount >= 1 && betAmount <= balance;
}
}

View file

@ -0,0 +1,66 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, catchError } from 'rxjs';
import { BlackjackGame } from '@blackjack/models/blackjack.model';
@Injectable({
providedIn: 'root',
})
export class BlackjackService {
private http = inject(HttpClient);
startGame(bet: number): Observable<BlackjackGame> {
return this.http
.post<BlackjackGame>('/backend/blackjack/start', { betAmount: bet }, { responseType: 'json' })
.pipe(
catchError((error) => {
console.error('Start game error:', error);
throw error;
})
);
}
hit(gameId: number): Observable<BlackjackGame> {
return this.http
.post<BlackjackGame>(`/backend/blackjack/${gameId}/hit`, {}, { responseType: 'json' })
.pipe(
catchError((error) => {
console.error('Hit error:', error);
throw error;
})
);
}
stand(gameId: number): Observable<BlackjackGame> {
return this.http
.post<BlackjackGame>(`/backend/blackjack/${gameId}/stand`, {}, { responseType: 'json' })
.pipe(
catchError((error) => {
console.error('Stand error:', error);
throw error;
})
);
}
doubleDown(gameId: number): Observable<BlackjackGame> {
return this.http
.post<BlackjackGame>(`/backend/blackjack/${gameId}/doubleDown`, {}, { responseType: 'json' })
.pipe(
catchError((error) => {
console.error('Double Down error:', error);
throw error;
})
);
}
getGame(gameId: number): Observable<BlackjackGame> {
return this.http
.get<BlackjackGame>(`/backend/blackjack/${gameId}`, { responseType: 'json' })
.pipe(
catchError((error) => {
console.error('Get game error:', error);
throw error;
})
);
}
}

View file

@ -0,0 +1,74 @@
import { Injectable } from '@angular/core';
import { Card } from '../models/blackjack.model';
import { GameState } from '../enum/gameState';
@Injectable({
providedIn: 'root',
})
export class GameControlsService {
calculateHandValue(cards: Card[]): number {
let sum = 0;
let aceCount = 0;
const rankValues: Record<string, number> = {
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: GameState): string {
switch (state) {
case GameState.IN_PROGRESS:
return 'Spiel läuft';
case GameState.PLAYER_WON:
return 'Gewonnen!';
case GameState.PLAYER_LOST:
return 'Verloren!';
case GameState.DRAW:
return 'Unentschieden!';
default:
return state;
}
}
getStatusClass(state: GameState): string {
switch (state) {
case GameState.PLAYER_WON:
return 'text-emerald';
case GameState.PLAYER_LOST:
return 'text-accent-red';
case GameState.DRAW:
return 'text-yellow-400';
default:
return 'text-white';
}
}
}

View file

@ -34,7 +34,9 @@
class="absolute bottom-4 left-4 right-4 transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300"
>
<h4 class="game-heading">{{ game.name }}</h4>
<button class="button-primary w-full py-2">Jetzt Spielen</button>
<button class="button-primary w-full py-2" (click)="navigateToGame(game.route)">
Jetzt Spielen
</button>
</div>
</div>
</div>
@ -59,7 +61,9 @@
class="absolute bottom-4 left-4 right-4 transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300"
>
<h4 class="game-heading">{{ game.name }}</h4>
<button class="button-primary w-full py-2">Jetzt Spielen</button>
<button class="button-primary w-full py-2" (click)="navigateToGame(game.route)">
Jetzt Spielen
</button>
</div>
</div>
</div>

View file

@ -1,11 +1,11 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { NavbarComponent } from '../../shared/components/navbar/navbar.component';
import { CurrencyPipe, NgFor } from '@angular/common';
import { Game } from '../../model/Game';
import { Transaction } from '../../model/Transaction';
import { DepositComponent } from '../deposit/deposit.component';
import { ConfirmationComponent } from '../../shared/components/confirmation/confirmation.component';
import { ActivatedRoute, Router } from '@angular/router';
import { ConfirmationComponent } from '@shared/components/confirmation/confirmation.component';
import { Transaction } from 'app/model/Transaction';
import { NavbarComponent } from '@shared/components/navbar/navbar.component';
import { Game } from 'app/model/Game';
@Component({
selector: 'app-homepage',
@ -38,31 +38,37 @@ export default class HomeComponent implements OnInit {
id: '1',
name: 'Poker',
image: '/poker.webp',
route: '/game/poker',
},
{
id: '2',
name: 'Blackjack',
image: '/blackjack.webp',
route: '/game/blackjack',
},
{
id: '3',
name: 'Slots',
image: '/slots.webp',
route: '/game/slots',
},
{
id: '4',
name: 'Plinko',
image: '/plinko.webp',
route: '/game/plinko',
},
{
id: '5',
name: 'Liars Dice',
image: '/liars-dice.webp',
route: '/game/liars-dice',
},
{
id: '6',
name: 'Lootboxen',
image: '/lootbox.webp',
route: '/game/lootbox',
},
];
@ -83,4 +89,8 @@ export default class HomeComponent implements OnInit {
closeDepositConfirmationModal() {
this.isDepositSuccessful = false;
}
navigateToGame(route: string) {
this.router.navigate([route]);
}
}

View file

@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from '@angular/core';
import { NavbarComponent } from '../../shared/components/navbar/navbar.component';
import { NgFor } from '@angular/common';
import { NavbarComponent } from '@shared/components/navbar/navbar.component';
@Component({
selector: 'app-landing-page',

View file

@ -1,7 +1,6 @@
import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../../service/auth.service';
import { User } from '../../model/User';
import { OAuthService } from 'angular-oauth2-oidc';
@Component({
@ -19,16 +18,16 @@ export default class LoginSuccessComponent implements OnInit {
async ngOnInit() {
console.log('Login success component initialized');
try {
// Handle code flow without throwing errors
const success = await this.oauthService.loadDiscoveryDocumentAndTryLogin();
console.log('Manual login attempt result:', success);
// If we have a valid access token, the user should be loaded in AuthService
const user = this.authService.getUser();
console.log('Login success user:', user);
// Check if we're authenticated
if (this.oauthService.hasValidAccessToken()) {
console.log('Valid access token found');

View file

@ -2,4 +2,5 @@ export interface Game {
id: string;
name: string;
image: string;
route: string;
}

View file

@ -1,6 +1,6 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, EMPTY, Observable } from 'rxjs';
import { BehaviorSubject, catchError, EMPTY, Observable, tap } from 'rxjs';
import { User } from '../model/User';
@Injectable({
@ -8,16 +8,37 @@ import { User } from '../model/User';
})
export class UserService {
private http: HttpClient = inject(HttpClient);
private currentUserSubject = new BehaviorSubject<User | null>(null);
public currentUser$ = this.currentUserSubject.asObservable();
constructor() {
// Initialize with current user data
this.getCurrentUser().subscribe();
}
public getUser(id: string): Observable<User | null> {
return this.http.get<User | null>(`/backend/user/${id}`).pipe(
catchError(() => EMPTY),
tap((user) => this.currentUserSubject.next(user))
);
}
public getCurrentUser(): Observable<User | null> {
return this.http.get<User | null>('/backend/user').pipe(catchError(() => EMPTY));
return this.http.get<User | null>('/backend/user').pipe(
catchError(() => EMPTY),
tap((user) => this.currentUserSubject.next(user))
);
}
public refreshCurrentUser(): void {
this.getCurrentUser().subscribe();
}
public createUser(id: string, username: string): Observable<User> {
return this.http.post<User>('/backend/user', {
authentikId: id,
username: username,
});
}).pipe(tap((user) => this.currentUserSubject.next(user)));
}
public getOrCreateUser(profile: any): Observable<User> {
@ -26,12 +47,12 @@ export class UserService {
// Check different possible locations for the ID and username
const id = profile.info?.sub || profile['sub'];
const username = profile.info?.preferred_username || profile['preferred_username'] || profile['email'] || profile['name'];
if (!id || !username) {
console.error('Could not extract user ID or username from profile', profile);
throw new Error('Invalid user profile data');
}
console.log(`Creating user with id: ${id}, username: ${username}`);
return this.createUser(id, username);
}

View file

@ -8,7 +8,7 @@ import {
AfterViewInit,
OnDestroy,
} from '@angular/core';
import { ModalAnimationService } from '../../services/modal-animation.service';
import { ModalAnimationService } from '@shared/services/modal-animation.service';
import gsap from 'gsap';
@Component({

View file

@ -0,0 +1,173 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { animate, style, transition, trigger } from '@angular/animations';
import { interval, Subscription, takeWhile } from 'rxjs';
@Component({
selector: 'app-debt-dialog',
standalone: true,
imports: [CommonModule],
template: `
<div *ngIf="visible" [@fadeInOut] class="modal-bg" style="z-index: 1000; position: fixed;">
<div class="modal-card" [@cardAnimation]>
<h2 class="modal-heading text-accent-red">WARNUNG!</h2>
<p class="py-2 text-text-secondary mb-4">
Du hast nicht genug Geld für den Double Down. Du bist jetzt im Minus und schuldest uns
{{ amount | currency: 'EUR' }}.
</p>
<p class="py-2 text-accent-red mb-4 font-bold">
Liefer das Geld sofort an den Dead Drop oder es wird unangenehme Konsequenzen geben!
</p>
<div
class="bg-deep-blue-light/50 rounded-lg p-5 mb-6 shadow-inner border border-deep-blue-light/30"
>
<div class="grid grid-cols-2 gap-4">
<div class="text-text-secondary">Schulden:</div>
<div class="font-medium text-right text-accent-red">{{ amount | currency: 'EUR' }}</div>
</div>
</div>
<div class="text-center mb-6">
<div
class="text-8xl font-bold text-accent-red"
[class.animate-pulse]="timeLeft() <= 10"
[class.animate-bounce]="timeLeft() <= 5"
[@countdown]="timeLeft()"
>
{{ timeLeft() }}
</div>
<div class="text-text-secondary mt-2">Sekunden verbleibend</div>
</div>
@if (timeLeft() === 0) {
<div class="text-center mb-6">
<div class="relative">
<div class="absolute inset-0 bg-accent-red/20 blur-xl rounded-full"></div>
<div
class="relative bg-gradient-to-b from-accent-red to-red-900 p-8 rounded-lg border-2 border-accent-red shadow-lg"
>
<div class="flex items-center justify-center gap-4 mb-4">
<svg
class="w-12 h-12 text-accent-red animate-[spin_2s_linear_infinite]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"
/>
</svg>
<span
class="text-4xl font-black tracking-wider text-white animate-[pulse_1s_ease-in-out_infinite]"
>
ZEIT ABGELAUFEN
</span>
<svg
class="w-12 h-12 text-accent-red animate-[spin_2s_linear_infinite]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"
/>
</svg>
</div>
<div
class="text-2xl font-bold text-white/90 tracking-wider animate-[pulse_1s_ease-in-out_infinite]"
>
KONSEQUENZEN FOLGEN
</div>
</div>
</div>
</div>
}
<button type="button" (click)="closeDialog()" class="button-primary w-full py-2">
Verstanden
</button>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('fadeInOut', [
transition(':enter', [
style({ opacity: 0 }),
animate('150ms ease-out', style({ opacity: 1 })),
]),
transition(':leave', [animate('150ms ease-in', style({ opacity: 0 }))]),
]),
trigger('cardAnimation', [
transition(':enter', [
style({ opacity: 0, transform: 'scale(0.95)' }),
animate('200ms ease-out', style({ opacity: 1, transform: 'scale(1)' })),
]),
]),
trigger('countdown', [
transition('* => *', [
style({ transform: 'scale(1.2)' }),
animate('100ms ease-out', style({ transform: 'scale(1)' })),
]),
]),
],
})
export class DebtDialogComponent implements OnInit, OnDestroy {
@Input() amount = 0;
@Input() set show(value: boolean) {
this.visible = value;
if (value) {
this.startTimer();
}
}
@Output() dialogClosed = new EventEmitter<void>();
visible = false;
timeLeft = signal(30);
private timerSubscription: Subscription | undefined;
private warningSound = new Audio('assets/sounds/warning.mp3');
ngOnInit() {
if (this.visible) {
this.startTimer();
}
}
ngOnDestroy() {
this.stopTimer();
}
private startTimer() {
this.timeLeft.set(30);
this.timerSubscription = interval(1000)
.pipe(takeWhile(() => this.timeLeft() > 0))
.subscribe(() => {
this.timeLeft.update((value) => value - 1);
if (this.timeLeft() <= 5) {
this.warningSound.play();
}
if (this.timeLeft() === 0) {
setTimeout(() => this.closeDialog(), 5000);
}
});
}
private stopTimer() {
if (this.timerSubscription) {
this.timerSubscription.unsubscribe();
}
}
closeDialog(): void {
this.stopTimer();
this.visible = false;
this.dialogClosed.emit();
}
}

View file

@ -17,8 +17,11 @@
@if (isLoggedIn) {
<div
class="text-white font-bold bg-deep-blue-contrast rounded-full px-4 py-2 text-sm hover:bg-deep-blue-contrast/80 hover:cursor-pointer hover:scale-105 transition-all active:scale-95 select-none duration-300"
routerLink="/home"
>
<span>Balance: {{ balance() | currency: 'EUR' : 'symbol' : '1.2-2' }}</span>
<span [class]="balance() < 0 ? 'text-accent-red' : ''">{{
balance() | currency: 'EUR' : 'symbol' : '1.2-2'
}}</span>
</div>
<button (click)="logout()" class="button-primary px-4 py-1.5">Abmelden</button>
}

View file

@ -1,8 +1,16 @@
import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
OnDestroy,
signal,
} from '@angular/core';
import { RouterModule } from '@angular/router';
import { UserService } from '../../../service/user.service';
import { CurrencyPipe } from '@angular/common';
import { AuthService } from '../../../service/auth.service';
import { CurrencyPipe } from '@angular/common';
import { UserService } from '@service/user.service';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-navbar',
@ -11,22 +19,27 @@ import { AuthService } from '../../../service/auth.service';
imports: [RouterModule, CurrencyPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NavbarComponent implements OnInit {
export class NavbarComponent implements OnInit, OnDestroy {
isMenuOpen = false;
private authService: AuthService = inject(AuthService);
isLoggedIn = this.authService.isLoggedIn();
private userService = inject(UserService);
private user = this.userService.getCurrentUser();
private userSubscription: Subscription | undefined;
public balance = signal(0);
ngOnInit() {
this.user.subscribe((user) => {
this.userSubscription = this.userService.currentUser$.subscribe((user) => {
this.balance.set(user?.balance ?? 0);
});
}
ngOnDestroy() {
if (this.userSubscription) {
this.userSubscription.unsubscribe();
}
}
login() {
try {
this.authService.login();

View file

@ -3,6 +3,13 @@
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@service/*": ["app/service/*"],
"@environments/*": ["environments/*"],
"@shared/*": ["app/shared/*"],
"@blackjack/*": ["app/feature/game/blackjack/*"]
},
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,