Compare commits

..

No commits in common. "main" and "v1.16.0" have entirely different histories.

67 changed files with 1048 additions and 3128 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. 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](https://git.kjan.de/SZUT/casino/wiki/Frontend#design-system) for design guidelines and component standards. Please refer to our [Style Guide](/frontend/README.md#style-guide) for design guidelines and component standards.
## Features ## Features
@ -67,11 +67,6 @@ cd backend
./gradlew bootRun ./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: The backend will be available at:
- API endpoint: http://localhost:8080 - API endpoint: http://localhost:8080
- Swagger documentation: http://localhost:8080/swagger - Swagger documentation: http://localhost:8080/swagger

View file

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

View file

@ -5,16 +5,3 @@ Content-Type: application/json
{ {
"betAmount": 1.01 "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,9 +0,0 @@
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

@ -12,7 +12,7 @@ Content-Type: application/json
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
{ {
"authentikId": "52cc0208-a3bd-4367-94c5-0404b016a003", "keycloakId": "52cc0208-a3bd-4367-94c5-0404b016a003",
"username": "john.doe" "username": "john.doe"
} }

View file

@ -1,20 +1,10 @@
package de.szut.casino; 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.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@SpringBootApplication @SpringBootApplication
public class CasinoApplication { public class CasinoApplication {
@ -26,65 +16,4 @@ public class CasinoApplication {
public static RestTemplate restTemplate() { public static RestTemplate restTemplate() {
return new 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

@ -6,12 +6,14 @@ import de.szut.casino.user.UserService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
@Slf4j @Slf4j
@ -26,91 +28,6 @@ public class BlackJackGameController {
this.userService = userService; 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") @PostMapping("/blackjack/start")
public ResponseEntity<Object> createBlackJackGame(@RequestBody @Valid CreateBlackJackGameDto createBlackJackGameDto, @RequestHeader("Authorization") String token) { public ResponseEntity<Object> createBlackJackGame(@RequestBody @Valid CreateBlackJackGameDto createBlackJackGameDto, @RequestHeader("Authorization") String token) {
Optional<UserEntity> optionalUser = userService.getCurrentUser(token); Optional<UserEntity> optionalUser = userService.getCurrentUser(token);

View file

@ -8,7 +8,6 @@ import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import org.hibernate.annotations.SQLRestriction;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
@ -29,37 +28,19 @@ public class BlackJackGameEntity {
@JsonIgnore @JsonIgnore
private UserEntity user; private UserEntity user;
// Expose UserID to JSON output
public Long getUserId() { public Long getUserId() {
return user != null ? user.getId() : null; return user != null ? user.getId() : null;
} }
@Enumerated(EnumType.STRING) private String state;
private BlackJackState state;
private BigDecimal bet; 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) @OneToMany(mappedBy = "game", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonManagedReference @JsonManagedReference
@SQLRestriction("card_type = 'PLAYER'")
private List<CardEntity> playerCards = new ArrayList<>(); private List<CardEntity> playerCards = new ArrayList<>();
@OneToMany(mappedBy = "game", cascade = CascadeType.ALL, orphanRemoval = true) @OneToMany(mappedBy = "game", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonManagedReference @JsonManagedReference
@SQLRestriction("card_type = 'DEALER'")
private List<CardEntity> dealerCards = new ArrayList<>(); 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

@ -3,308 +3,48 @@ package de.szut.casino.blackjack;
import de.szut.casino.user.UserEntity; import de.szut.casino.user.UserEntity;
import de.szut.casino.user.UserRepository; import de.szut.casino.user.UserRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import java.util.Random; import java.util.Random;
@Service @Service
public class BlackJackService { public class BlackJackService {
private final BlackJackGameRepository blackJackGameRepository; private final BlackJackGameRepository blackJackGameRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final Random random = new Random();
public BlackJackService(BlackJackGameRepository blackJackGameRepository, UserRepository userRepository) { public BlackJackService(BlackJackGameRepository blackJackGameRepository, UserRepository userRepository) {
this.blackJackGameRepository = blackJackGameRepository; this.blackJackGameRepository = blackJackGameRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
} }
public BlackJackGameEntity getBlackJackGame(Long id) { private final Random random = new Random();
return blackJackGameRepository.findById(id).orElse(null);
}
@Transactional
public BlackJackGameEntity createBlackJackGame(UserEntity user, BigDecimal betAmount) { public BlackJackGameEntity createBlackJackGame(UserEntity user, BigDecimal betAmount) {
BlackJackGameEntity game = new BlackJackGameEntity(); BlackJackGameEntity game = new BlackJackGameEntity();
game.setUser(user); game.setUser(user);
game.setBet(betAmount); game.setBet(betAmount);
initializeDeck(game); for (int i = 0; i < 2; i++) {
dealInitialCards(game); CardEntity playerCard = createRandomCard(game);
game.getPlayerCards().add(playerCard);
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); CardEntity dealerCard = createRandomCard(game);
game.getDealerCards().add(dealerCard);
updateGameStateAndBalance(game); user.setBalance(user.getBalance().subtract(betAmount));
return blackJackGameRepository.save(game); userRepository.save(user);
} 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; return game;
} }
@Transactional private CardEntity createRandomCard(BlackJackGameEntity game) {
public BlackJackGameEntity split(BlackJackGameEntity game) { CardEntity card = new CardEntity();
if (game.getState() != BlackJackState.IN_PROGRESS || card.setGame(game);
game.getPlayerCards().size() != 2 || card.setSuit(Suit.values()[random.nextInt(Suit.values().length)]);
game.isSplit() || card.setRank(Rank.values()[random.nextInt(Rank.values().length)]);
!game.getPlayerCards().get(0).getRank().equals(game.getPlayerCards().get(1).getRank())) { return card;
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

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

View file

@ -1,5 +1,6 @@
package de.szut.casino.blackjack; package de.szut.casino.blackjack;
import com.fasterxml.jackson.annotation.JsonBackReference; import com.fasterxml.jackson.annotation.JsonBackReference;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
@ -29,12 +30,4 @@ public class CardEntity {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private Rank rank; private Rank rank;
@Enumerated(EnumType.STRING)
@JsonIgnore
private CardType cardType;
}
enum CardType {
DECK, PLAYER, DEALER, PLAYER_SPLIT
} }

View file

@ -47,19 +47,15 @@ public class DepositController {
public ResponseEntity<SessionIdDto> checkout(@RequestBody @Valid AmountDto amountDto, @RequestHeader("Authorization") String token) throws StripeException { public ResponseEntity<SessionIdDto> checkout(@RequestBody @Valid AmountDto amountDto, @RequestHeader("Authorization") String token) throws StripeException {
Stripe.apiKey = stripeKey; Stripe.apiKey = stripeKey;
KeycloakUserDto userData = getAuthentikUserInfo(token); KeycloakUserDto userData = getKeycloakUserInfo(token);
Optional<UserEntity> optionalUserEntity = this.userRepository.findOneByAuthentikId(userData.getSub()); Optional<UserEntity> optionalUserEntity = this.userRepository.findOneByKeycloakId(userData.getSub());
SessionCreateParams params = SessionCreateParams.builder() SessionCreateParams params = SessionCreateParams.builder()
.addLineItem(SessionCreateParams.LineItem.builder() .addLineItem(SessionCreateParams.LineItem.builder()
.setPriceData(SessionCreateParams.LineItem.PriceData.builder() .setAmount((long) amountDto.getAmount() * 100)
.setCurrency("EUR") .setCurrency("EUR")
.setUnitAmount((long) amountDto.getAmount() * 100)
.setProductData(SessionCreateParams.LineItem.PriceData.ProductData.builder()
.setName("Einzahlung")
.build())
.build())
.setQuantity(1L) .setQuantity(1L)
.setName("Einzahlung")
.build()) .build())
.setSuccessUrl(frontendHost+"/home?success=true") .setSuccessUrl(frontendHost+"/home?success=true")
.setCancelUrl(frontendHost+"/home?success=false") .setCancelUrl(frontendHost+"/home?success=false")
@ -77,10 +73,10 @@ public class DepositController {
return ResponseEntity.ok(new SessionIdDto(session.getId())); return ResponseEntity.ok(new SessionIdDto(session.getId()));
} }
private KeycloakUserDto getAuthentikUserInfo(String token) { private KeycloakUserDto getKeycloakUserInfo(String token) {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", token); headers.set("Authorization", token);
ResponseEntity<KeycloakUserDto> response = this.restTemplate.exchange("https://oauth.simonis.lol/application/o/userinfo/", HttpMethod.GET, new HttpEntity<>(headers), KeycloakUserDto.class); ResponseEntity<KeycloakUserDto> response = this.restTemplate.exchange("http://localhost:9090/realms/LF12/protocol/openid-connect/userinfo", HttpMethod.GET, new HttpEntity<>(headers), KeycloakUserDto.class);
return response.getBody(); return response.getBody();
} }

View file

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

View file

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

View file

@ -1,58 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,30 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,24 +0,0 @@
package de.szut.casino.security;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
@Override
public AbstractAuthenticationToken convert(Jwt source) {
JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
return converter.convert(source);
}
public <U> Converter<Jwt, U> andThen(Converter<? super AbstractAuthenticationToken, ? extends U> after) {
return Converter.super.andThen(after);
}
}

View file

@ -0,0 +1,48 @@
package de.szut.casino.security;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
@Slf4j
@Component
public class KeycloakLogoutHandler implements LogoutHandler {
private final RestTemplate restTemplate;
public KeycloakLogoutHandler(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication auth) {
logout(request, auth);
}
public void logout(HttpServletRequest request, Authentication auth) {
logoutFromKeycloak((OidcUser) auth.getPrincipal());
}
private void logoutFromKeycloak(OidcUser user) {
String endSessionEndpoint = user.getIssuer() + "/protocol/openid-connect/logout";
UriComponentsBuilder builder = UriComponentsBuilder
.fromUriString(endSessionEndpoint)
.queryParam("id_token_hint", user.getIdToken().getTokenValue());
ResponseEntity<String> logoutResponse = restTemplate.getForEntity(builder.toUriString(), String.class);
if (logoutResponse.getStatusCode().is2xxSuccessful()) {
log.info("Successfulley logged out from Keycloak");
} else {
log.error("Could not propagate logout to Keycloak");
}
}
}

View file

@ -0,0 +1,82 @@
package de.szut.casino.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Configuration
@EnableWebSecurity
class KeycloakSecurityConfig {
private final KeycloakLogoutHandler keycloakLogoutHandler;
KeycloakSecurityConfig(KeycloakLogoutHandler keycloakLogoutHandler) {
this.keycloakLogoutHandler = keycloakLogoutHandler;
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
return new RegisterSessionAuthenticationStrategy(sessionRegistry());
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf
.ignoringRequestMatchers("/webhook")
)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/webhook").permitAll()
.requestMatchers("/swagger", "/swagger-ui/**", "/v3/api-docs/**", "/health").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(spec -> spec.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess != null && realmAccess.containsKey("roles")) {
List<String> roles = (List<String>) realmAccess.get("roles");
for (String role : roles) {
grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
}
return grantedAuthorities;
});
return jwtAuthenticationConverter;
}
}

View file

@ -1,49 +0,0 @@
package de.szut.casino.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> {
auth.requestMatchers("/swagger/**", "/swagger-ui/**", "/health").permitAll()
.anyRequest().authenticated();
})
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(new CustomJwtAuthenticationConverter())
));
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:4200"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("authorization", "content-type", "x-auth-token", "Access-Control-Allow-Origin"));
configuration.setExposedHeaders(List.of("x-auth-token"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View file

@ -5,6 +5,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
@ -22,13 +23,20 @@ public class UserController {
@Autowired @Autowired
private UserService userService; private UserService userService;
@GetMapping("/user/{id}")
public ResponseEntity<?> getUser(@PathVariable String id) {
if (id == null || !userService.exists(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(userService.getUser(id));
}
@PostMapping("/user") @PostMapping("/user")
public ResponseEntity<?> createUser(@RequestBody @Valid CreateUserDto userData) { public ResponseEntity<?> createUser(@RequestBody @Valid CreateUserDto userData) {
if (userService.exists(userData.getAuthentikId())) { if (userService.exists(userData.getKeycloakId())) {
HttpHeaders headers = new HttpHeaders();
headers.add("Location", "/user");
return new ResponseEntity<>(headers, HttpStatus.FOUND); return this.redirect("/user/" + userData.getKeycloakId());
} }
return ResponseEntity.ok(userService.createUser(userData)); return ResponseEntity.ok(userService.createUser(userData));
@ -44,4 +52,11 @@ public class UserController {
return ResponseEntity.ok(userData); return ResponseEntity.ok(userData);
} }
private ResponseEntity<Object> redirect(String route) {
HttpHeaders headers = new HttpHeaders();
headers.add("Location", route);
return new ResponseEntity<>(headers, HttpStatus.FOUND);
}
} }

View file

@ -19,14 +19,14 @@ public class UserEntity {
@GeneratedValue @GeneratedValue
private Long id; private Long id;
@Column(unique = true) @Column(unique = true)
private String authentikId; private String keycloakId;
private String username; private String username;
@Column(precision = 19, scale = 2) @Column(precision = 19, scale = 2)
private BigDecimal balance; private BigDecimal balance;
public UserEntity(String authentikId, String username, BigDecimal balance) { public UserEntity(String keycloakId, String username, BigDecimal balance) {
this.authentikId = authentikId; this.keycloakId = keycloakId;
this.username = username; this.username = username;
this.balance = balance; this.balance = balance;
} }

View file

@ -9,11 +9,10 @@ import java.math.BigDecimal;
@Service @Service
public class UserMappingService { public class UserMappingService {
public GetUserDto mapToGetUserDto(UserEntity user) { public GetUserDto mapToGetUserDto(UserEntity user) {
return new GetUserDto(user.getAuthentikId(), user.getUsername(), user.getBalance()); return new GetUserDto(user.getKeycloakId(), user.getUsername(), user.getBalance());
} }
public UserEntity mapToUserEntity(CreateUserDto createUserDto) { public UserEntity mapToUserEntity(CreateUserDto createUserDto) {
return new UserEntity(createUserDto.getAuthentikId(), createUserDto.getUsername(), BigDecimal.ZERO); return new UserEntity(createUserDto.getKeycloakId(), createUserDto.getUsername(), BigDecimal.ZERO); }
}
} }

View file

@ -8,8 +8,8 @@ import java.util.Optional;
@Service @Service
public interface UserRepository extends JpaRepository<UserEntity, Long> { public interface UserRepository extends JpaRepository<UserEntity, Long> {
@Query("SELECT u FROM UserEntity u WHERE u.authentikId = ?1") @Query("SELECT u FROM UserEntity u WHERE u.keycloakId = ?1")
Optional<UserEntity> findOneByAuthentikId(String authentikId); Optional<UserEntity> findOneByKeycloakId(String keycloakId);
boolean existsByAuthentikId(String authentikId); boolean existsByKeycloakId(String keycloakId);
} }

View file

@ -1,8 +1,7 @@
package de.szut.casino.user; package de.szut.casino.user;
import de.szut.casino.user.dto.CreateUserDto; import java.util.Optional;
import de.szut.casino.user.dto.GetUserDto;
import de.szut.casino.user.dto.KeycloakUserDto;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@ -11,7 +10,9 @@ import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import java.util.Optional; import de.szut.casino.user.dto.CreateUserDto;
import de.szut.casino.user.dto.GetUserDto;
import de.szut.casino.user.dto.KeycloakUserDto;
@Service @Service
public class UserService { public class UserService {
@ -31,51 +32,41 @@ public class UserService {
return user; return user;
} }
public GetUserDto getUser(String authentikId) { public GetUserDto getUser(String keycloakId) {
Optional<UserEntity> user = this.userRepository.findOneByAuthentikId(authentikId); Optional<UserEntity> user = this.userRepository.findOneByKeycloakId(keycloakId);
return user.map(userEntity -> mappingService.mapToGetUserDto(userEntity)).orElse(null); return user.map(userEntity -> mappingService.mapToGetUserDto(userEntity)).orElse(null);
} }
public GetUserDto getCurrentUserAsDto(String token) { public GetUserDto getCurrentUserAsDto(String token) {
KeycloakUserDto userData = getAuthentikUserInfo(token); KeycloakUserDto userData = getKeycloakUserInfo(token);
if (userData == null) { if (userData == null) {
return null; return null;
} }
Optional<UserEntity> user = this.userRepository.findOneByAuthentikId(userData.getSub()); Optional<UserEntity> user = this.userRepository.findOneByKeycloakId(userData.getSub());
return user.map(userEntity -> mappingService.mapToGetUserDto(userEntity)).orElse(null); return user.map(userEntity -> mappingService.mapToGetUserDto(userEntity)).orElse(null);
} }
public Optional<UserEntity> getCurrentUser(String token) { public Optional<UserEntity> getCurrentUser(String token) {
KeycloakUserDto userData = getAuthentikUserInfo(token); KeycloakUserDto userData = getKeycloakUserInfo(token);
if (userData == null) { if (userData == null) {
return Optional.empty(); return Optional.empty();
} }
return this.userRepository.findOneByAuthentikId(userData.getSub()); return this.userRepository.findOneByKeycloakId(userData.getSub());
} }
private KeycloakUserDto getAuthentikUserInfo(String token) { private KeycloakUserDto getKeycloakUserInfo(String token) {
try { HttpHeaders headers = new HttpHeaders();
HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", token);
headers.set("Authorization", token); ResponseEntity<KeycloakUserDto> response = this.http.exchange("http://localhost:9090/realms/LF12/protocol/openid-connect/userinfo", HttpMethod.GET, new HttpEntity<>(headers), KeycloakUserDto.class);
ResponseEntity<KeycloakUserDto> response = this.http.exchange(
"https://oauth.simonis.lol/application/o/userinfo/",
HttpMethod.GET,
new HttpEntity<>(headers),
KeycloakUserDto.class
);
return response.getBody(); return response.getBody();
} catch (Exception e) {
System.err.println("Error fetching user info from Authentik: " + e.getMessage());
return null;
}
} }
public boolean exists(String authentikId) { public boolean exists(String keycloakId) {
return userRepository.existsByAuthentikId(authentikId); return userRepository.existsByKeycloakId(keycloakId);
} }
} }

View file

@ -10,6 +10,6 @@ import lombok.Setter;
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
public class CreateUserDto { public class CreateUserDto {
private String authentikId; private String keycloakId;
private String username; private String username;
} }

View file

@ -12,7 +12,7 @@ import java.math.BigDecimal;
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
public class GetUserDto { public class GetUserDto {
private String authentikId; private String keycloakId;
private String username; private String username;
private BigDecimal balance; private BigDecimal balance;
} }

View file

@ -9,31 +9,16 @@ app.frontend-host=http://localhost:4200
spring.application.name=lf12_starter spring.application.name=lf12_starter
#client registration configuration #client registration configuration
spring.security.oauth2.client.registration.keycloak.client-id=lf12
spring.security.oauth2.client.registration.authentik.client-id=MDqjm1kcWKuZfqHJXjxwAV20i44aT7m4VhhTL3Nm spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.authentik.client-secret=GY2F8te6iAVYt1TNAUVLzWZEXb6JoMNp6chbjqaXNq4gS5xTDL54HqBiAlV1jFKarN28LQ7FUsYX4SbwjfEhZhgeoKuBnZKjR9eiu7RawnGgxIK9ffvUfMkjRxnmiGI5 spring.security.oauth2.client.registration.keycloak.scope=openid
spring.security.oauth2.client.registration.authentik.provider=authentik
spring.security.oauth2.client.registration.authentik.client-name=Authentik
spring.security.oauth2.client.registration.authentik.scope=openid,email,profile
spring.security.oauth2.client.registration.authentik.client-authentication-method=client_secret_basic
spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
# Provider settings
spring.security.oauth2.client.provider.authentik.issuer-uri=https://oauth.simonis.lol/application/o/casino-dev/
spring.security.oauth2.client.provider.authentik.authorization-uri=https://oauth.simonis.lol/application/o/authorize/
spring.security.oauth2.client.provider.authentik.token-uri=https://oauth.simonis.lol/application/o/token/
spring.security.oauth2.client.provider.authentik.user-info-uri=https://oauth.simonis.lol/application/o/userinfo/
spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://oauth.simonis.lol/application/o/casino-dev/jwks/
spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username
# Resource server config
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://oauth.simonis.lol/application/o/casino-dev/
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://oauth.simonis.lol/application/o/casino-dev/jwks/
#OIDC provider configuration: #OIDC provider configuration:
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:9090/realms/LF12
spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username
logging.level.org.springframework.security=DEBUG logging.level.org.springframework.security=DEBUG
#validating JWT token against our Authentik server #validating JWT token against our Keycloak server
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9090/realms/LF12
springdoc.swagger-ui.path=swagger springdoc.swagger-ui.path=swagger
springdoc.swagger-ui.try-it-out-enabled=true springdoc.swagger-ui.try-it-out-enabled=true

View file

@ -1,122 +0,0 @@
package de.szut.casino.user;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.szut.casino.user.dto.CreateUserDto;
import de.szut.casino.user.dto.GetUserDto;
@WebMvcTest(UserController.class)
@AutoConfigureMockMvc(addFilters = false)
public class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private UserService userService;
private GetUserDto getUserDto;
private CreateUserDto createUserDto;
private UserEntity testUser;
private final String TEST_ID = "test-id-123";
private final String AUTH_TOKEN = "Bearer test-token";
@BeforeEach
void setUp() {
getUserDto = new GetUserDto();
getUserDto.setAuthentikId(TEST_ID);
getUserDto.setUsername("testuser");
testUser = new UserEntity();
testUser.setAuthentikId(TEST_ID);
testUser.setUsername("testuser");
createUserDto = new CreateUserDto();
createUserDto.setAuthentikId(TEST_ID);
createUserDto.setUsername("testuser");
}
@Test
void getUserByIdSuccess() throws Exception {
when(userService.exists(TEST_ID)).thenReturn(true);
when(userService.getUser(TEST_ID)).thenReturn(getUserDto);
mockMvc.perform(get("/user/" + TEST_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.authentikId").value(TEST_ID))
.andExpect(jsonPath("$.username").value("testuser"));
}
@Test
void getUserByIdNotFound() throws Exception {
when(userService.exists(TEST_ID)).thenReturn(false);
mockMvc.perform(get("/user/" + TEST_ID))
.andExpect(status().isNotFound());
}
@Test
void createUserSuccess() throws Exception {
when(userService.exists(TEST_ID)).thenReturn(false);
when(userService.createUser(any(CreateUserDto.class))).thenReturn(testUser);
mockMvc.perform(post("/user")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createUserDto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.authentikId").value(TEST_ID))
.andExpect(jsonPath("$.username").value("testuser"));
}
@Test
void createUserAlreadyExists() throws Exception {
when(userService.exists(TEST_ID)).thenReturn(true);
mockMvc.perform(post("/user")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createUserDto)))
.andExpect(status().isFound())
.andExpect(header().string("Location", "/user/" + TEST_ID));
}
@Test
void getCurrentUserSuccess() throws Exception {
when(userService.getCurrentUser(AUTH_TOKEN)).thenReturn(getUserDto);
mockMvc.perform(get("/user")
.header("Authorization", AUTH_TOKEN))
.andExpect(status().isOk())
.andExpect(jsonPath("$.authentikId").value(TEST_ID))
.andExpect(jsonPath("$.username").value("testuser"));
}
@Test
void getCurrentUserNotFound() throws Exception {
when(userService.getCurrentUser(anyString())).thenReturn(null);
mockMvc.perform(get("/user")
.header("Authorization", AUTH_TOKEN))
.andExpect(status().isNotFound());
}
}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,12 +1,13 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { Component, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { KeycloakAngularModule } from 'keycloak-angular';
import { FooterComponent } from './shared/components/footer/footer.component'; import { FooterComponent } from './shared/components/footer/footer.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [CommonModule, RouterOutlet, FooterComponent], imports: [CommonModule, RouterOutlet, KeycloakAngularModule, FooterComponent],
providers: [], providers: [],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.css', styleUrl: './app.component.css',

View file

@ -1,24 +1,59 @@
import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core'; import {
APP_INITIALIZER,
ApplicationConfig,
provideExperimentalZonelessChangeDetection,
} from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; import {
KeycloakAngularModule,
KeycloakBearerInterceptor,
KeycloakService,
} from 'keycloak-angular';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc';
import { httpInterceptor } from './shared/interceptor/http.interceptor'; export const initializeKeycloak = (keycloak: KeycloakService) => async () =>
keycloak.init({
config: {
url: 'http://localhost:9090',
realm: 'LF12',
clientId: 'lf12',
},
loadUserProfileAtStartUp: true,
initOptions: {
onLoad: 'check-sso',
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
checkLoginIframe: false,
redirectUri: window.location.origin + '/',
},
});
function initializeApp(keycloak: KeycloakService): () => Promise<boolean> {
return () => initializeKeycloak(keycloak)();
}
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideRouter(routes), provideRouter(routes),
KeycloakAngularModule,
FontAwesomeModule, FontAwesomeModule,
provideHttpClient(withInterceptors([httpInterceptor])),
provideExperimentalZonelessChangeDetection(),
provideAnimationsAsync(),
provideOAuthClient(),
{ {
provide: OAuthStorage, provide: APP_INITIALIZER,
useFactory: () => localStorage, useFactory: initializeApp,
multi: true,
deps: [KeycloakService],
}, },
KeycloakService,
provideHttpClient(withInterceptorsFromDi()),
provideExperimentalZonelessChangeDetection(),
{
provide: HTTP_INTERCEPTORS,
useClass: KeycloakBearerInterceptor,
multi: true,
},
provideAnimationsAsync(),
], ],
}; };

View file

@ -8,7 +8,7 @@ export const routes: Routes = [
component: LandingComponent, component: LandingComponent,
}, },
{ {
path: 'auth/callback', path: 'login/success',
loadComponent: () => import('./feature/login-success/login-success.component'), loadComponent: () => import('./feature/login-success/login-success.component'),
}, },
{ {

View file

@ -1,12 +1,12 @@
import { CanActivateFn, Router } from '@angular/router'; import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { AuthService } from './service/auth.service'; import { KeycloakService } from 'keycloak-angular';
export const authGuard: CanActivateFn = async () => { export const authGuard: CanActivateFn = async () => {
const authService = inject(AuthService); const keycloakService = inject(KeycloakService);
const router = inject(Router); const router = inject(Router);
if (authService.isLoggedIn()) { if (keycloakService.isLoggedIn()) {
return true; return true;
} }

View file

@ -16,17 +16,17 @@ import {
} from '@angular/core'; } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { loadStripe, Stripe } from '@stripe/stripe-js'; import { loadStripe, Stripe } from '@stripe/stripe-js';
import { DepositService } from '../../service/deposit.service';
import { debounceTime } from 'rxjs'; import { debounceTime } from 'rxjs';
import { CommonModule } from '@angular/common'; import { environment } from '../../../environments/environment';
import { NgIf } from '@angular/common';
import { ModalAnimationService } from '../../shared/services/modal-animation.service';
import gsap from 'gsap'; import gsap from 'gsap';
import { DepositService } from '@service/deposit.service';
import { environment } from '@environments/environment';
import { ModalAnimationService } from '@shared/services/modal-animation.service';
@Component({ @Component({
selector: 'app-deposit', selector: 'app-deposit',
standalone: true, standalone: true,
imports: [ReactiveFormsModule, CommonModule], imports: [ReactiveFormsModule, NgIf],
templateUrl: './deposit.component.html', templateUrl: './deposit.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })

View file

@ -3,44 +3,21 @@
<div class="container mx-auto px-4 py-6 space-y-8"> <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="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div class="lg:col-span-3 space-y-6 flex flex-col gap-4"> <div class="lg:col-span-3 space-y-6 flex flex-col gap-4">
<app-dealer-hand [cards]="dealerCards()"></app-dealer-hand> <app-dealer-hand [cards]="dealerCards"></app-dealer-hand>
<app-player-hand [cards]="playerCards()"></app-player-hand> <app-player-hand [cards]="playerCards"></app-player-hand>
<app-game-controls
@if (gameInProgress()) { (hit)="onHit()"
<app-game-controls (stand)="onStand()"
[playerCards]="playerCards()" (leave)="leaveGame()"
[gameState]="gameState()" ></app-game-controls>
[isActionInProgress]="isActionInProgress()"
(hit)="onHit()"
(stand)="onStand()"
(doubleDown)="onDoubleDown()"
(leave)="leaveGame()"
></app-game-controls>
}
</div> </div>
<div class="lg:col-span-1 space-y-6"> <div class="lg:col-span-1 space-y-6">
<app-game-info <app-game-info
[balance]="balance()" [balance]="balance()"
[currentBet]="currentBet()" [currentBet]="currentBet"
[gameInProgress]="gameInProgress()" (newGame)="onNewGame()"
[isActionInProgress]="isActionInProgress()"
(newGame)="onNewGame($event)"
></app-game-info> ></app-game-info>
</div> </div>
</div> </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

@ -1,20 +1,14 @@
import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NavbarComponent } from '../../../shared/components/navbar/navbar.component';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { UserService } from '../../../service/user.service';
import { PlayingCardComponent } from './components/playing-card/playing-card.component'; import { PlayingCardComponent } from './components/playing-card/playing-card.component';
import { DealerHandComponent } from './components/dealer-hand/dealer-hand.component'; import { DealerHandComponent } from './components/dealer-hand/dealer-hand.component';
import { PlayerHandComponent } from './components/player-hand/player-hand.component'; import { PlayerHandComponent } from './components/player-hand/player-hand.component';
import { GameControlsComponent } from './components/game-controls/game-controls.component'; import { GameControlsComponent } from './components/game-controls/game-controls.component';
import { GameInfoComponent } from './components/game-info/game-info.component'; import { GameInfoComponent } from './components/game-info/game-info.component';
import { Card, BlackjackGame } from '@blackjack/models/blackjack.model'; import { Card } from './models/card.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({ @Component({
selector: 'app-blackjack', selector: 'app-blackjack',
@ -27,200 +21,43 @@ import { DebtDialogComponent } from '@shared/components/debt-dialog/debt-dialog.
PlayerHandComponent, PlayerHandComponent,
GameControlsComponent, GameControlsComponent,
GameInfoComponent, GameInfoComponent,
GameResultComponent,
DebtDialogComponent,
], ],
templateUrl: './blackjack.component.html', templateUrl: './blackjack.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export default class BlackjackComponent implements OnInit { export default class BlackjackComponent {
private router = inject(Router); private router = inject(Router);
private userService = inject(UserService); private userService = inject(UserService);
private blackjackService = inject(BlackjackService);
dealerCards = signal<Card[]>([]); dealerCards: Card[] = [
playerCards = signal<Card[]>([]); { value: '2', suit: '♥', hidden: false },
currentBet = signal(0); { value: '3', suit: '♦', hidden: false },
{ value: 'B', suit: '□', hidden: true },
];
playerCards: Card[] = [];
currentBet = 0;
balance = 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); constructor() {
this.userService.getCurrentUser().subscribe((user) => {
showDebtDialog = signal(false); this.balance.set(user?.balance ?? 0);
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.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 { onHit(): void {
if (!this.currentGameId() || this.isActionInProgress()) return; // Implementation for hit action
this.isActionInProgress.set(true);
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 { onStand(): void {
if (!this.currentGameId() || this.isActionInProgress()) return; // Implementation for stand action
if (this.gameState() !== GameState.IN_PROGRESS) {
console.log('Cannot stand: game is not in progress');
return;
}
this.isActionInProgress.set(true);
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.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 { leaveGame(): void {
this.router.navigate(['/home']); this.router.navigate(['/home']);
} }
onNewGame(): void {
// Implementation for new game
}
} }

View file

@ -1,83 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
SimpleChanges,
ElementRef,
ViewChild,
AfterViewInit,
} from '@angular/core';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { CountUp } from 'countup.js';
@Component({
selector: 'app-animated-number',
standalone: true,
imports: [CommonModule, CurrencyPipe],
template: ` <span #numberElement>{{ formattedValue }}</span> `,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AnimatedNumberComponent implements OnChanges, AfterViewInit {
@Input() value = 0;
@Input() duration = 1;
@Input() ease = 'power1.out';
@ViewChild('numberElement') numberElement!: ElementRef;
private countUp: CountUp | null = null;
private previousValue = 0;
formattedValue = '0,00 €';
ngAfterViewInit(): void {
this.initializeCountUp();
if (this.countUp && this.value !== 0) {
this.countUp.start(() => {
this.previousValue = this.value;
});
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['value']) {
if (this.countUp) {
const endVal = this.value;
this.countUp.update(endVal);
this.previousValue = endVal;
} else {
this.formattedValue = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(this.value);
}
}
}
private initializeCountUp(): void {
if (this.numberElement) {
this.countUp = new CountUp(this.numberElement.nativeElement, this.value, {
startVal: this.previousValue,
duration: this.duration,
easingFn: (t, b, c, d) => {
if (this.ease === 'power1.out') {
return c * (1 - Math.pow(1 - t / d, 1)) + b;
}
return c * (t / d) + b;
},
formattingFn: (value) => {
const formatted = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
this.formattedValue = formatted;
return formatted;
},
});
}
}
}

View file

@ -1,8 +1,7 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Card } from '@blackjack/models/blackjack.model';
import { PlayingCardComponent } from '../playing-card/playing-card.component'; import { PlayingCardComponent } from '../playing-card/playing-card.component';
import { GameControlsService } from '@blackjack/services/game-controls.service'; import { Card } from '../../models/card.model';
@Component({ @Component({
selector: 'app-dealer-hand', selector: 'app-dealer-hand',
@ -10,64 +9,21 @@ import { GameControlsService } from '@blackjack/services/game-controls.service';
imports: [CommonModule, PlayingCardComponent], imports: [CommonModule, PlayingCardComponent],
template: ` template: `
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between items-center"> <h3 class="section-heading text-2xl mb-4">Croupier's Karten</h3>
<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="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"> <div class="flex justify-center gap-4 min-h-[160px] p-4 border-2 border-red-400 rounded-lg">
@if (cards.length > 0) { <app-playing-card
@for (card of cardsWithState; track card.id) { *ngFor="let card of cards"
<app-playing-card [value]="card.value"
[rank]="card.rank" [suit]="card.suit"
[suit]="card.suit" [hidden]="card.hidden"
[hidden]="card.hidden" ></app-playing-card>
[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> </div>
</div> </div>
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class DealerHandComponent implements OnChanges { export class DealerHandComponent {
@Input() cards: Card[] = []; @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

@ -1,81 +1,36 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core';
import { CommonModule } from '@angular/common'; 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({ @Component({
selector: 'app-game-controls', selector: 'app-game-controls',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
template: ` template: `
<div class="flex flex-col gap-4"> <div class="flex justify-center gap-4">
<div class="flex justify-center text-lg mb-5"> <button
<div class="card p-4"> (click)="hit.emit()"
<div class="text-emerald font-bold mb-1"> class="button-primary px-8 py-4 text-lg font-medium min-w-[120px]"
Deine Punkte: {{ gameControlsService.calculateHandValue(playerCards) }} >
</div> Ziehen
<div class="text-text-secondary"> </button>
Status: <button
<span [class]="gameControlsService.getStatusClass(gameState)">{{ (click)="stand.emit()"
gameControlsService.getStatusText(gameState) class="button-primary px-8 py-4 text-lg font-medium min-w-[120px]"
}}</span> >
</div> Halten
</div> </button>
</div> <button
<div class="flex justify-center gap-4"> (click)="leave.emit()"
<button 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"
(click)="hit.emit()" >
class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative" Abbrechen
[disabled]="gameState !== GameState.IN_PROGRESS || isActionInProgress" </button>
>
<span>Ziehen</span>
</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"
>
<span>Halten</span>
</button>
<button
(click)="doubleDown.emit()"
class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative"
[disabled]="!canDoubleDown || isActionInProgress"
>
<span>Verdoppeln</span>
</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"
>
Abbrechen
</button>
</div>
</div> </div>
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class GameControlsComponent { export class GameControlsComponent {
@Input() playerCards: Card[] = [];
@Input() gameState: GameState = GameState.IN_PROGRESS;
@Input() isActionInProgress = false;
@Output() hit = new EventEmitter<void>(); @Output() hit = new EventEmitter<void>();
@Output() stand = new EventEmitter<void>(); @Output() stand = new EventEmitter<void>();
@Output() doubleDown = new EventEmitter<void>();
@Output() leave = new EventEmitter<void>(); @Output() leave = new EventEmitter<void>();
protected readonly GameState = GameState;
constructor(protected gameControlsService: GameControlsService) {}
get canDoubleDown(): boolean {
return (
this.gameState === GameState.IN_PROGRESS &&
this.playerCards.length === 2 &&
!this.isActionInProgress
);
}
} }

View file

@ -1,150 +1,32 @@
import { import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
Output,
SimpleChanges,
signal,
} from '@angular/core';
import { CommonModule, CurrencyPipe } from '@angular/common'; import { CommonModule, CurrencyPipe } from '@angular/common';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { BettingService } from '@blackjack/services/betting.service';
import { AnimatedNumberComponent } from '../animated-number/animated-number.component';
@Component({ @Component({
selector: 'app-game-info', selector: 'app-game-info',
standalone: true, standalone: true,
imports: [CommonModule, CurrencyPipe, ReactiveFormsModule, AnimatedNumberComponent], imports: [CommonModule, CurrencyPipe],
template: ` template: `
<div class="card p-4"> <div class="card p-4">
<h3 class="section-heading text-xl mb-4">Spiel Informationen</h3> <h3 class="section-heading text-xl mb-4">Spiel Informationen</h3>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between items-center">
<span class="text-text-secondary">Guthaben:</span>
<span class="text-emerald">{{ balance | currency: 'EUR' }}</span>
</div>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-text-secondary">Aktuelle Wette:</span> <span class="text-text-secondary">Aktuelle Wette:</span>
<span [class]="currentBet > 0 ? 'text-accent-red' : 'text-text-secondary'"> <span [class]="currentBet > 0 ? 'text-accent-red' : 'text-text-secondary'">
<app-animated-number [value]="currentBet" [duration]="0.5"></app-animated-number> {{ currentBet | currency: 'EUR' }}
</span> </span>
</div> </div>
<button class="button-primary w-full py-2" (click)="newGame.emit()">Neues Spiel</button>
@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>
</div> </div>
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class GameInfoComponent implements OnChanges { export class GameInfoComponent {
@Input() set balance(value: number) { @Input() balance = 0;
this._balance.set(value);
}
get balance() {
return this._balance();
}
private _balance = signal(0);
@Input() currentBet = 0; @Input() currentBet = 0;
@Input() gameInProgress = false; @Output() newGame = new EventEmitter<void>();
@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

@ -1,135 +0,0 @@
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';
import { AnimatedNumberComponent } from '../animated-number/animated-number.component';
@Component({
selector: 'app-game-result',
standalone: true,
imports: [CommonModule, CurrencyPipe, AnimatedNumberComponent],
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">
<app-animated-number [value]="amount" [duration]="0.5"></app-animated-number>
</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 ? '-' : '+' }}
<app-animated-number
[value]="isWin ? amount * 2 : amount"
[duration]="0.5"
></app-animated-number>
<div *ngIf="isWin" class="text-xs text-text-secondary">
(Einsatz
<app-animated-number [value]="amount" [duration]="0.5"></app-animated-number> × 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">
<app-animated-number [value]="balance" [duration]="0.5"></app-animated-number>
</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

@ -1,8 +1,7 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { PlayingCardComponent } from '../playing-card/playing-card.component'; import { PlayingCardComponent } from '../playing-card/playing-card.component';
import { Card } from '@blackjack/models/blackjack.model'; import { Card } from '../../models/card.model';
import { GameControlsService } from '@blackjack/services/game-controls.service';
@Component({ @Component({
selector: 'app-player-hand', selector: 'app-player-hand',
@ -10,66 +9,23 @@ import { GameControlsService } from '@blackjack/services/game-controls.service';
imports: [CommonModule, PlayingCardComponent], imports: [CommonModule, PlayingCardComponent],
template: ` template: `
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between items-center"> <h3 class="section-heading text-2xl mb-4">Deine Karten</h3>
<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="card p-6 !bg-emerald">
<div <div
class="flex justify-center gap-4 min-h-[160px] p-4 border-2 border-emerald-400 rounded-lg" class="flex justify-center gap-4 min-h-[160px] p-4 border-2 border-emerald-400 rounded-lg"
> >
@if (cards.length > 0) { <app-playing-card
@for (card of cardsWithState; track card.id) { *ngFor="let card of cards"
<app-playing-card [value]="card.value"
[rank]="card.rank" [suit]="card.suit"
[suit]="card.suit" [hidden]="card.hidden"
[hidden]="card.hidden" ></app-playing-card>
[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> </div>
</div> </div>
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class PlayerHandComponent implements OnChanges { export class PlayerHandComponent {
@Input() cards: Card[] = []; @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

@ -1,15 +1,5 @@
import { import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
ChangeDetectionStrategy,
Component,
Input,
AfterViewInit,
ElementRef,
OnChanges,
SimpleChanges,
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { gsap } from 'gsap';
import { Suit, suitSymbols } from '@blackjack/models/blackjack.model';
@Component({ @Component({
selector: 'app-playing-card', selector: 'app-playing-card',
@ -17,117 +7,24 @@ import { Suit, suitSymbols } from '@blackjack/models/blackjack.model';
imports: [CommonModule], imports: [CommonModule],
template: ` template: `
<div <div
#cardElement class="w-24 h-36 rounded-lg p-2 relative flex flex-col justify-between shadow-lg"
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'" [class]="hidden ? 'bg-red-800' : 'bg-white'"
> >
@if (!hidden) { <span *ngIf="!hidden" class="text-xl font-bold text-accent-red">{{ value }}</span>
<span class="text-xl font-bold" [class]="isRedSuit ? 'text-accent-red' : 'text-black'">{{ <span
getDisplayRank(rank) *ngIf="!hidden"
}}</span> class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-3xl text-accent-red"
} >{{ suit }}</span
@if (!hidden) { >
<span <span *ngIf="!hidden" class="text-xl font-bold text-accent-red self-end rotate-180">{{
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-3xl" value
[class]="isRedSuit ? 'text-accent-red' : 'text-black'" }}</span>
>{{ 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> </div>
`, `,
styles: [
`
.card-element {
transform-style: preserve-3d;
backface-visibility: hidden;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class PlayingCardComponent implements AfterViewInit, OnChanges { export class PlayingCardComponent {
@Input({ required: true }) rank!: string; @Input({ required: true }) value!: string;
@Input({ required: true }) suit!: Suit; @Input({ required: true }) suit!: string;
@Input({ required: true }) hidden!: boolean; @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

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

View file

@ -1,23 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -1,66 +0,0 @@
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

@ -1,74 +0,0 @@
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

@ -1,11 +1,11 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { NavbarComponent } from '../../shared/components/navbar/navbar.component';
import { CurrencyPipe, NgFor } from '@angular/common'; import { CurrencyPipe, NgFor } from '@angular/common';
import { Game } from '../../model/Game';
import { Transaction } from '../../model/Transaction';
import { DepositComponent } from '../deposit/deposit.component'; import { DepositComponent } from '../deposit/deposit.component';
import { ConfirmationComponent } from '../../shared/components/confirmation/confirmation.component';
import { ActivatedRoute, Router } from '@angular/router'; 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({ @Component({
selector: 'app-homepage', selector: 'app-homepage',

View file

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

View file

@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
import { UserService } from '../../service/user.service';
import { KeycloakService } from 'keycloak-angular';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService } from '../../service/auth.service';
import { OAuthService } from 'angular-oauth2-oidc';
@Component({ @Component({
selector: 'app-login-success', selector: 'app-login-success',
@ -12,32 +12,15 @@ import { OAuthService } from 'angular-oauth2-oidc';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export default class LoginSuccessComponent implements OnInit { export default class LoginSuccessComponent implements OnInit {
private authService: AuthService = inject(AuthService); private userService: UserService = inject(UserService);
private oauthService: OAuthService = inject(OAuthService); private keycloakService: KeycloakService = inject(KeycloakService);
private router: Router = inject(Router); private router: Router = inject(Router);
async ngOnInit() { async ngOnInit() {
try { const userProfile = await this.keycloakService.loadUserProfile();
if (this.oauthService.hasValidAccessToken()) { const user = await this.userService.getOrCreateUser(userProfile);
this.router.navigate(['/home']); sessionStorage.setItem('user', JSON.stringify(user));
} else {
setTimeout(() => { this.router.navigate(['home']);
if (this.oauthService.hasValidAccessToken() || this.authService.getUser()) {
this.router.navigate(['/home']);
} else {
this.router.navigate(['/']);
}
}, 3000);
}
} catch (err) {
console.error('Error during login callback:', err);
setTimeout(() => {
if (this.authService.isLoggedIn()) {
this.router.navigate(['/home']);
} else {
this.router.navigate(['/']);
}
}, 3000);
}
} }
} }

View file

@ -1,5 +1,5 @@
export interface User { export interface User {
authentikId: string; keycloakId: string;
username: string; username: string;
balance: number; balance: number;
} }

View file

@ -1,208 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { AuthConfig, OAuthEvent, OAuthService } from 'angular-oauth2-oidc';
import { UserService } from './user.service';
import { User } from '../model/User';
import { Router } from '@angular/router';
import { environment } from '../../environments/environment';
import { catchError, from, of } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class AuthService {
private readonly authConfig: AuthConfig = {
issuer: 'https://oauth.simonis.lol/application/o/casino-dev/',
clientId: environment.OAUTH_CLIENT_ID,
dummyClientSecret: environment.OAUTH_CLIENT_SECRET,
scope: `openid email profile ${environment.OAUTH_CLIENT_ID}`,
responseType: 'code',
redirectUri: window.location.origin + '/auth/callback',
postLogoutRedirectUri: '',
redirectUriAsPostLogoutRedirectUriFallback: false,
oidc: true,
requestAccessToken: true,
tokenEndpoint: 'https://oauth.simonis.lol/application/o/token/',
userinfoEndpoint: 'https://oauth.simonis.lol/application/o/userinfo/',
strictDiscoveryDocumentValidation: false,
skipIssuerCheck: true,
disableAtHashCheck: true,
requireHttps: false,
showDebugInformation: false,
sessionChecksEnabled: false,
};
private userService: UserService = inject(UserService);
private oauthService: OAuthService = inject(OAuthService);
private router: Router = inject(Router);
private user: User | null = null;
constructor() {
this.oauthService.configure(this.authConfig);
this.setupEventHandling();
const hasAuthParams =
window.location.search.includes('code=') ||
window.location.search.includes('token=') ||
window.location.search.includes('id_token=');
if (hasAuthParams) {
this.processCodeFlow();
} else {
this.checkExistingSession();
}
}
private processCodeFlow() {
this.oauthService
.tryLogin({
onTokenReceived: () => {
this.handleSuccessfulLogin();
},
})
.catch((err) => {
console.error('Error processing code flow:', err);
});
}
private checkExistingSession() {
this.oauthService
.loadDiscoveryDocumentAndTryLogin()
.then((isLoggedIn) => {
if (isLoggedIn && !this.user) {
this.handleSuccessfulLogin();
}
})
.catch((err) => {
console.error('Error during initial login attempt:', err);
});
}
private setupEventHandling() {
this.oauthService.events.subscribe((event: OAuthEvent) => {
if (event.type === 'token_received') {
this.handleSuccessfulLogin();
}
});
}
private handleSuccessfulLogin() {
const claims = this.oauthService.getIdentityClaims();
if (claims && (claims['sub'] || claims['email'])) {
this.processUserProfile(claims);
return;
}
try {
from(this.oauthService.loadUserProfile())
.pipe(
catchError((error) => {
console.error('Error loading user profile:', error);
if (this.oauthService.hasValidAccessToken()) {
this.oauthService.getAccessToken();
const minimalProfile = {
sub: 'user-' + Math.random().toString(36).substring(2, 10),
preferred_username: 'user' + Date.now(),
};
return of({ info: minimalProfile });
}
return of(null);
})
)
.subscribe((profile) => {
if (profile) {
this.processUserProfile(profile);
} else {
this.router.navigate(['/']);
}
});
} catch (err) {
console.error('Exception in handleSuccessfulLogin:', err);
if (this.oauthService.hasValidAccessToken()) {
this.router.navigate(['/home']);
} else {
this.router.navigate(['/']);
}
}
}
private processUserProfile(profile: unknown) {
this.fromUserProfile(profile as Record<string, unknown>).subscribe({
next: (user) => {
this.user = user;
this.router.navigate(['home']);
},
error: (err) => {
console.error('Error creating/retrieving user:', err);
if (this.oauthService.hasValidAccessToken()) {
this.router.navigate(['/home']);
} else {
this.router.navigate(['/']);
}
},
});
}
login() {
try {
this.oauthService
.loadDiscoveryDocument()
.then(() => {
this.oauthService.initLoginFlow();
})
.catch((err) => {
console.error('Error loading discovery document:', err);
this.oauthService.initLoginFlow();
});
} catch (err) {
console.error('Exception in login:', err);
const redirectUri = this.authConfig.redirectUri || window.location.origin + '/auth/callback';
const scope = this.authConfig.scope || 'openid email profile';
const authUrl = `${this.authConfig.issuer}authorize?client_id=${this.authConfig.clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
window.location.href = authUrl;
}
}
logout() {
try {
this.user = null;
this.oauthService.logOut(true);
if (window.location.href.includes('id_token') || window.location.href.includes('logout')) {
window.location.href = window.location.origin;
}
localStorage.removeItem('access_token');
localStorage.removeItem('id_token');
localStorage.removeItem('refresh_token');
sessionStorage.removeItem('access_token');
sessionStorage.removeItem('id_token');
sessionStorage.removeItem('refresh_token');
this.router.navigate(['/']);
} catch (err) {
console.error('Exception in logout:', err);
localStorage.clear();
sessionStorage.clear();
this.router.navigate(['/']);
}
}
isLoggedIn() {
return this.oauthService.hasValidAccessToken();
}
private fromUserProfile(profile: Record<string, unknown>) {
return this.userService.getOrCreateUser(profile);
}
getAccessToken() {
return this.oauthService.getAccessToken();
}
getUser() {
return this.user;
}
}

View file

@ -1,6 +1,7 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, catchError, EMPTY, Observable, tap } from 'rxjs'; import { KeycloakProfile } from 'keycloak-js';
import { catchError, EMPTY, Observable } from 'rxjs';
import { User } from '../model/User'; import { User } from '../model/User';
@Injectable({ @Injectable({
@ -8,53 +9,34 @@ import { User } from '../model/User';
}) })
export class UserService { export class UserService {
private http: HttpClient = inject(HttpClient); private http: HttpClient = inject(HttpClient);
private currentUserSubject = new BehaviorSubject<User | null>(null);
public currentUser$ = this.currentUserSubject.asObservable();
constructor() {
this.getCurrentUser().subscribe();
}
public getUser(id: string): Observable<User | null> { public getUser(id: string): Observable<User | null> {
return this.http.get<User | null>(`/backend/user/${id}`).pipe( return this.http.get<User | null>(`/backend/user/${id}`).pipe(catchError(() => EMPTY));
catchError(() => EMPTY),
tap((user) => this.currentUserSubject.next(user))
);
} }
public getCurrentUser(): Observable<User | null> { public getCurrentUser(): Observable<User | null> {
return this.http.get<User | null>('/backend/user').pipe( return this.http.get<User | null>('/backend/user').pipe(catchError(() => EMPTY));
catchError(() => EMPTY),
tap((user) => this.currentUserSubject.next(user))
);
}
public refreshCurrentUser(): void {
this.getCurrentUser().subscribe();
} }
public createUser(id: string, username: string): Observable<User> { public createUser(id: string, username: string): Observable<User> {
return this.http return this.http.post<User>('/backend/user', {
.post<User>('/backend/user', { keycloakId: id,
authentikId: id, username: username,
username: username, });
})
.pipe(tap((user) => this.currentUserSubject.next(user)));
} }
public getOrCreateUser(profile: Record<string, unknown>): Observable<User> { public async getOrCreateUser(userProfile: KeycloakProfile) {
const info = profile['info'] as Record<string, unknown> | undefined; if (userProfile.id == null) {
const id = (info?.['sub'] as string) || (profile['sub'] as string); return;
const username =
(info?.['preferred_username'] as string) ||
(profile['preferred_username'] as string) ||
(profile['email'] as string) ||
(profile['name'] as string);
if (!id || !username) {
throw new Error('Invalid user profile data');
} }
return await this.getUser(userProfile.id)
.toPromise()
.then(async (user) => {
if (user) {
return user;
}
return this.createUser(id, username); return await this.createUser(userProfile.id ?? '', userProfile.username ?? '').toPromise();
});
} }
} }

View file

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

View file

@ -1,176 +0,0 @@
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';
import { AnimatedNumberComponent } from '@blackjack/components/animated-number/animated-number.component';
@Component({
selector: 'app-debt-dialog',
standalone: true,
imports: [CommonModule, AnimatedNumberComponent],
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
<app-animated-number [value]="amount" [duration]="0.5"></app-animated-number>.
</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">
<app-animated-number [value]="amount" [duration]="0.5"></app-animated-number>
</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,11 +17,8 @@
@if (isLoggedIn) { @if (isLoggedIn) {
<div <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" 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 [class]="balance() < 0 ? 'text-accent-red' : ''"> <span>Guthaben: {{ balance() | currency: 'EUR' : 'symbol' : '1.2-2' }}</span>
<app-animated-number [value]="balance()" [duration]="0.5"></app-animated-number>
</span>
</div> </div>
<button (click)="logout()" class="button-primary px-4 py-1.5">Abmelden</button> <button (click)="logout()" class="button-primary px-4 py-1.5">Abmelden</button>
} }

View file

@ -1,56 +1,43 @@
import { import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
ChangeDetectionStrategy,
Component,
inject,
OnInit,
OnDestroy,
signal,
} from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { AuthService } from '../../../service/auth.service'; import { KeycloakService } from 'keycloak-angular';
import { CurrencyPipe } from '@angular/common'; import { UserService } from '../../../service/user.service';
import { UserService } from '@service/user.service';
import { Subscription } from 'rxjs';
import { AnimatedNumberComponent } from '@blackjack/components/animated-number/animated-number.component';
import { CurrencyPipe } from '@angular/common';
@Component({ @Component({
selector: 'app-navbar', selector: 'app-navbar',
templateUrl: './navbar.component.html', templateUrl: './navbar.component.html',
standalone: true, standalone: true,
imports: [RouterModule, CurrencyPipe, AnimatedNumberComponent], imports: [RouterModule, CurrencyPipe],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class NavbarComponent implements OnInit, OnDestroy { export class NavbarComponent implements OnInit {
isMenuOpen = false; isMenuOpen = false;
private authService: AuthService = inject(AuthService); private keycloakService: KeycloakService = inject(KeycloakService);
isLoggedIn = this.authService.isLoggedIn(); isLoggedIn = this.keycloakService.isLoggedIn();
private userService = inject(UserService); private userService = inject(UserService);
private userSubscription: Subscription | undefined; private user = this.userService.getCurrentUser();
public balance = signal(0); public balance = signal(0);
ngOnInit() { ngOnInit() {
this.userSubscription = this.userService.currentUser$.subscribe((user) => { this.user.subscribe((user) => {
this.balance.set(user?.balance ?? 0); this.balance.set(user?.balance ?? 0);
}); });
} }
ngOnDestroy() {
if (this.userSubscription) {
this.userSubscription.unsubscribe();
}
}
login() { login() {
try { try {
this.authService.login(); const baseUrl = window.location.origin;
this.keycloakService.login({ redirectUri: `${baseUrl}/login/success` });
} catch (error) { } catch (error) {
console.error('Login failed:', error); console.error('Login failed:', error);
} }
} }
logout() { logout() {
this.authService.logout(); this.keycloakService.logout();
} }
toggleMenu() { toggleMenu() {

View file

@ -1,21 +0,0 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { OAuthStorage } from 'angular-oauth2-oidc';
export const httpInterceptor: HttpInterceptorFn = (req, next) => {
const oauthStorage = inject(OAuthStorage);
if (oauthStorage.getItem('access_token')) {
return next(
req.clone({
setHeaders: {
Authorization: 'Bearer ' + oauthStorage.getItem('access_token'),
'Access-Control-Allow-Origin': '*',
'Referrer-Policy': 'no-referrer',
},
})
);
} else {
return next(req);
}
};

View file

@ -1,7 +1,4 @@
export const environment = { export const environment = {
STRIPE_KEY: STRIPE_KEY:
'pk_test_51QrePYIvCfqz7ANgMizBorPpVjJ8S6gcaL4yvcMQnVaKyReqcQ6jqaQEF7aDZbDu8rNVsTZrw8ABek4ToxQX7KZe00jpGh8naG', 'pk_test_51QrePYIvCfqz7ANgMizBorPpVjJ8S6gcaL4yvcMQnVaKyReqcQ6jqaQEF7aDZbDu8rNVsTZrw8ABek4ToxQX7KZe00jpGh8naG',
OAUTH_CLIENT_ID: 'MDqjm1kcWKuZfqHJXjxwAV20i44aT7m4VhhTL3Nm',
OAUTH_CLIENT_SECRET:
'GY2F8te6iAVYt1TNAUVLzWZEXb6JoMNp6chbjqaXNq4gS5xTDL54HqBiAlV1jFKarN28LQ7FUsYX4SbwjfEhZhgeoKuBnZKjR9eiu7RawnGgxIK9ffvUfMkjRxnmiGI5',
}; };

View file

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