-
Notifications
You must be signed in to change notification settings - Fork 18
/
GameEngine.java
828 lines (720 loc) · 33.8 KB
/
GameEngine.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
package org.hit.android.haim.texasholdem.common.model.game;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.*;
import org.hit.android.haim.texasholdem.common.model.bean.chat.Channel;
import org.hit.android.haim.texasholdem.common.model.bean.game.*;
import org.hit.android.haim.texasholdem.common.util.CustomThreadFactory;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
/**
* A class that represents a game between several players<br/>
* A game has some hashcode that users can use in order to connect to the same game and play together.<br/>
* Game class will manage the gameplay, and support chat mechanism to let the players communicate with each other.
*
* @author Haim Adrian
* @since 08-May-21
*/
@Data
@NoArgsConstructor
@ToString(exclude = {"chat", "deck", "gameLog", "listener", "playersLock"})
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class GameEngine {
private static final AtomicInteger gameCounter = new AtomicInteger(100); // Assume there can be 900 games running in parallel
/**
* Accept 7 players at most.
*/
public static final int MAXIMUM_AMOUNT_OF_PLAYERS = 7;
/**
* A unique identifier of this game
*/
@EqualsAndHashCode.Include
private int id;
/**
* The game hash.<br/>
* Game hash is used so players can join game by this unique hash. The hash is generated by
* the server when a player creates a new network game.<br/>
* There is a default value we set in this class at {@link #initGameHash()}, though it is overridden
* at ServerGameEngine, and then the same value is set by Jackson when we return game info to client.<br/>
* As a result of having this class in the common project, we had to keep the hasher private for server,
* and not expose it as a common module. Hence there is this weird setting of game hash.
*/
protected String gameHash;
/**
* Game preferences.
* @see GameSettings
*/
private GameSettings gameSettings;
/**
* The {@link Players} playing in this game
*/
private Players players;
/**
* The player assigned the Dealer role.<br/>
* This player is modified in every single round, clockwise.
*/
private Player dealer;
/**
* The player assigned the small blind role.<br/>
* This is the first player to speak during a game, hence we keep a reference to this player.
*/
@JsonIgnore
private Player smallBlindPlayer;
/**
* The player assigned the bing blind role.<br/>
* Before the flop, this is the last player to speak, so we need to check that this player
* did not raise, in order to show the flop.
*/
@JsonIgnore
private Player bigBlindPlayer;
/**
* The chat of current game.
* @see Channel
*/
@JsonIgnore // Ignore it as we have special MessageController to get chat info
private Channel chat;
/**
* The log of current round in a game.
* @see GameLog
*/
private GameLog gameLog;
/**
* Deck of cards we use in order to play.<br/>
* Selecting cards for players and board.
* @see Deck
*/
@JsonIgnore // Do not expose the deck, to avoid of revealing next cards
private Deck deck;
/**
* The board at which we set game cards: 3 X flop, 1 X Turn and 1 X River.
* @see Board
*/
private Board board;
/**
* The pot of a running game.
* @see Pot
*/
private Pot pot;
/**
* Use a {@link PlayerTurnTimer} in network games to limit the time for each player turn
* to {@link GameSettings#getTurnTime()} minute, such that the game continues.<br/>
* In case of a timeout, the current player is forced to fold.
*/
private PlayerTurnTimer playerTurnTimer;
/**
* Keep a reference to the last action kind, so we can make sure there are no illegal
* actions. For example, a CHECK is not allowed after CALL or RAISE.<br/>
* This reference is set to {@code null} when a bet round is started, to allow any action.<br/>
* This variable is used as a stack to hold "history" of last actions, such that we let clients
* to search for the last non FOLD action
*/
private ArrayDeque<PlayerActionKind> lastActionKind; // ArrayDeque and not Deque, so we will have access to clone()
/**
* Map between every player to his last action, so we will be able to detect the case when
* current player calls and next player raised. In this case, next player cannot raise again.
* Instead, we should open next card.
*/
private Map<String, PlayerAction> playerToHisLastAction;
/**
* A listener to get notified upon player updates, so we can persist changes in chips amount.
*/
@JsonIgnore
private PlayerUpdateListener listener;
@JsonIgnore
private PlayerUpdateNotifier notifier;
/**
* When a round is over, we keep a reference to each player's earnings so the client can
* retrieve that and show indication.<br/>
* After several seconds, a new round will be started automatically.<br/>
* Note that as long as this variable differs from null, all player actions are ignored.
*/
//@JsonSerialize(keyUsing = Player.PlayerKeySerializer.class)
//@JsonDeserialize(keyUsing = Player.PlayerKeyDeserializer.class)
private Map<String, Pot.PlayerWinning> playerToEarnings;
/**
* When this engine was created, time is in milliseconds since epoch.<br/>
* We keep it to automatically cleanup inactive games. A game is consider inactive when it
* is opened for 1 hour, and there is no player, or one player only.
*/
private long timeCreated;
/**
* A state machine containing the current state of game engine. See {@link GameState}
*/
private AtomicReference<GameState> gameState;
/**
* A lock to protect {@link #players}, such that we will not allow for more than 7 players to join
*/
@JsonIgnore
private final Lock playersLock = new ReentrantLock();
/**
* Constructs a new {@link GameEngine}
* @param gameSettings Preferences of a game.
* @param listener A listener to get notified upon player updates, so we can persist changes in chips amount.
*/
public GameEngine(@NonNull GameSettings gameSettings, @NonNull PlayerUpdateListener listener) {
this.gameSettings = gameSettings;
this.listener = listener;
notifier = new PlayerUpdateNotifier();
timeCreated = System.currentTimeMillis();
id = gameCounter.getAndIncrement();
initGameHash();
players = new Players();
chat = Channel.builder().name(getGameHash()).build();
gameLog = new GameLog();
deck = new Deck();
board = new Board();
pot = new Pot();
playerTurnTimer = gameSettings.isNetwork() ? new PlayerTurnTimer(this::onPlayerTurnTimeout, gameSettings.getTurnTime()) : null;
gameState = new AtomicReference<>(GameState.READY);
playerToHisLastAction = new HashMap<>();
info("GameEngine created: " + this);
}
/**
* See {@link #gameHash}
*/
protected void initGameHash() {
// By default, use game identifier
this.gameHash = String.valueOf(getId());
}
/**
* Override this method to use the correct logging service.<br/>
* For server, this is Log4j2, and for client, this is Android's Log.<br/>
* We log game operations during a game, to follow where the game is at some specific time.
* @param message The message to log
*/
protected void info(String message) {
// By default, log to system out
System.out.println(message);
}
/**
* @return A unique hash (~4 characters) representing this game
*/
public String getGameHash() {
return gameHash;
}
/**
* Add a player to this game.
* @param player The player to add
*/
public void addPlayer(Player player) {
if (isActive()) {
throw new IllegalArgumentException("Cannot join an active game. Wait for round to end.");
} else {
// Lock on players, to make sure we do not let more than 7 players to join a game.
if (players.size() < MAXIMUM_AMOUNT_OF_PLAYERS) {
playersLock.lock();
try {
if (players.size() < MAXIMUM_AMOUNT_OF_PLAYERS) {
info(getId() + " - Adding player: " + player);
players.addPlayer(player);
chat.getUsers().add(player);
}
} finally {
playersLock.unlock();
}
}
}
}
/**
* Add a player to this game.
*
* @param player The player to add
*/
public void removePlayer(Player player) {
if (player == null) {
return;
}
info(getId() + " - Removing player: " + player);
// If it is the current player who leaves, execute FOLD action
Player playerById = players.getPlayerById(player.getId());
if (playerById != null) {
try {
if (players.getCurrentPlayerIndex() == players.indexOfPlayer(playerById)) {
executePlayerAction(playerById, PlayerAction.builder().name(playerById.getName()).actionKind(PlayerActionKind.FOLD).build());
}
} catch (Exception ignore) {
}
playersLock.lock();
try {
players.removePlayer(playerById);
chat.getUsers().remove(playerById);
} finally {
playersLock.unlock();
}
}
}
/**
* Starts a game.<br/>
* Shuffling the deck, generating random dealer, taking small bet from the player sits after
* the dealer, and big bet from the player after the small one. Then, after the mandatory bets,
* we {@link #moveTurnForward()} to the next player.
*/
public void start() {
if (gameState.compareAndSet(GameState.READY, GameState.STARTED)) {
info(getId() + " - Starting new game.");
// Generate random dealer
List<Integer> availablePlayers = new ArrayList<>(players.size());
players.getPlayers().forEach(p -> availablePlayers.add(p.getPosition()));
int dealerIndex = new Random().nextInt(availablePlayers.size());
dealer = players.getPlayer(availablePlayers.get(dealerIndex));
// Start the round. (Set min player as the current player, and take mandatory bets)
startRound();
}
}
/**
* Execute a player move. This can be check, call, raise or fold.<br/>
* We ask for the acting player to ensure that it is his turn. If not, an IllegalArgumentException will be thrown.
*
* @param player The player who makes the move
* @param action What move to make
* @throws IllegalArgumentException In case the specified player is not the current player, or not playing, or action is illegal
*/
public void executePlayerAction(Player player, PlayerAction action) throws IllegalArgumentException {
info(getId() + " - Executing player action. [player=" + player + ", action=" + action + "]");
validatePlayerAction(player, action);
if (playerToEarnings != null) {
info("Player action was ignored because there is currently player earnings available. So as long as it is available," +
" all player actions are ignored. Clients expected to read game state and wait for next round. [player=" + player + ", action=" + action + "]");
return;
}
Player currPlayer = players.getCurrentPlayer();
switch (action.getActionKind()) {
case CALL: {
long amount = pot.getLastBet() == null ? action.getChips().get() : pot.getLastBet();
long chips = pot.bet(currPlayer, amount - pot.getPotOfPlayer(currPlayer));
action.setChips(chips);
// Update listener about update of chips
notifier.notifyPlayerChipsUpdated(currPlayer, -1 * chips);
break;
}
case RAISE: {
// In case of a RAISE, make sure player does not raise with more chips than he has.
long validatedChips = Math.min(player.getChips().get(), action.getChips().get());
long chips = pot.bet(currPlayer, validatedChips);
action.setChips(chips);
// Update listener about update of chips
notifier.notifyPlayerChipsUpdated(currPlayer, -1 * chips);
break;
}
case FOLD: {
currPlayer.setPlaying(false);
break;
}
default:
// Nothing special.
}
gameLog.logAction(action);
if (action.getActionKind() != null) {
lastActionKind.push(action.getActionKind());
}
playerToHisLastAction.put(currPlayer.getId(), action);
// Everytime it is the dealer's turn we can continue to next game stage, unless the dealer is raising
// Another option is before the flop is shown. In this case, big blind player is the last to act.
Player activeDealer = players.getAvailablePlayingPlayerReversed(players.indexOfPlayer(dealer));
boolean isBetRoundOver = (action.getActionKind() != PlayerActionKind.RAISE) &&
((board.getFlop1().isPresent() && currPlayer.equals(activeDealer)) || (!board.getFlop1().isPresent() && currPlayer.equals(bigBlindPlayer)));
// Another option for bet round to over is in case the current action is CALL, and the next player
// is the one with the highest raise. Of course this is irrelevant before the flop, since the bigBlind player
// by default is the highest raise, though he is the last to act.
// There might be another case when the last player to speak folds. In this case, we set true to isBetRoundOver above,
// but it might be that there was a raise that we need to continue to scan the board until we get to that player who raised.
Player playerWithHighestRaise = findPlayerWithHighestRaise();
if (((action.getActionKind() == PlayerActionKind.CALL) || (action.getActionKind() == PlayerActionKind.FOLD)) &&
(playerWithHighestRaise != null) &&
board.getFlop1().isPresent()) {
isBetRoundOver = playerWithHighestRaise.equals(players.getAvailablePlayingPlayer(currPlayer.getPosition() + 1));
}
// If it is last player standing, he won.
List<Player> playersLeft = players.getInvolvedPlayers().stream().filter(p -> p.getChips().get() > 0).collect(Collectors.toList());
if ((playersLeft.size() == 0) || ((playersLeft.size() == 1) && (currPlayer.getId().equals(playersLeft.get(0).getId()) || (action.getActionKind() == PlayerActionKind.FOLD)))) {
isBetRoundOver = true;
}
if (isBetRoundOver) {
// If we opened a new card, clear last bet to start a new bet round.
// When the river card is already opened we do not want to clear last bet, so we will be
// able to recognize that the game has finished.
if ((playersLeft.size() > 1) && showNextCard()) {
pot.clearLastBet();
pot.clearPotsOfRound();
playerToHisLastAction.clear();
// Set dealer as the current player, so we will move to small blind player down below.
players.setCurrentPlayerIndex(dealer.getPosition());
// Reset last action, as we start a new round of bets
lastActionKind.clear();
} else {
applyWinIfNeeded();
}
}
if (playerToEarnings == null) {
moveTurnForward();
}
}
/**
* Use this method to find the latest non FOLD action.<br/>
* We need this so client will not assume latest action was fold, and display a CHECK button, although
* the latest non fold action was RAISE/CALL.
* @return Latest non FOLD action, or {@link null}
*/
public PlayerActionKind findLastNonFoldAction() {
PlayerActionKind action = null;
if ((lastActionKind != null) && !lastActionKind.isEmpty()) {
// Work on a copy so we will not affect the original stack.
Deque<PlayerActionKind> copyOfLastActionKind = lastActionKind.clone();
while ((action == null) && (!copyOfLastActionKind.isEmpty())) {
PlayerActionKind currActionToTry = copyOfLastActionKind.pop();
if (currActionToTry != PlayerActionKind.FOLD) {
action = currActionToTry;
}
}
}
return action;
}
/**
* Use this method to find the player that raised the highest amount of chips.<br/>
* This is needed so we can know at which player we should stop a round of bets. Meaning that
* if current player calls, and the next player is the one who started a raise, then we should open
* a card and not let the next player raise again.<br/>
* We lookup for the highest because there might be re-raise operations.
* @return Player with highest raise, or null in case no one raised.
*/
private Player findPlayerWithHighestRaise() {
Set<Player> involvedPlayers = players.getInvolvedPlayers();
Player highestRaise = null;
for (Player player : involvedPlayers) {
PlayerAction lastAction = playerToHisLastAction.get(player.getId());
if ((lastAction != null) && (lastAction.getActionKind() == PlayerActionKind.RAISE)) {
if ((highestRaise == null) || (pot.getPotOfPlayer(highestRaise) < pot.getPotOfPlayer(player))) {
// Ignore the current player. We are looking for another player
if (!player.equals(players.getCurrentPlayer())) {
highestRaise = player;
}
}
}
}
return highestRaise;
}
/**
* Validates that a player can run the specified player action
*
* @param player A player to validate
* @param action The action to validate
* @throws IllegalArgumentException In case the specified player is not the current player, or not playing, or action is illegal
*/
private void validatePlayerAction(Player player, PlayerAction action) {
if (!player.equals(players.getCurrentPlayer())) {
throw new IllegalArgumentException("Cannot execute player action when it is not the player's turn.");
}
if (!player.isPlaying()) {
throw new IllegalArgumentException("Player is not playing.");
}
if (!action.getActionKind().canComeAfter(lastActionKind.isEmpty() ? null : lastActionKind.getFirst())) {
// Big blind player is allowed to check in case last call was same as the big blind.
if (!player.equals(bigBlindPlayer) || (action.getActionKind() != PlayerActionKind.CHECK) || (pot.getLastBet() != gameSettings.getBigBet()))
throw new IllegalArgumentException(action.getActionKind().name() + " is not allowed after " + lastActionKind);
}
if (pot.getLastBet() != null) {
// We might get here with a CHECK after FOLD, though there could be a bet before the FOLD, hence fix this.
if (action.getActionKind() == PlayerActionKind.CHECK) {
PlayerAction prevPlayerAction = playerToHisLastAction.get(player.getId());
// CHECK is ok when player raised last time we visited him. Otherwise, we need to fix it to CALL.
// In addition, cannot check in case there is a "future" player that raised over current player.
Player playerWithHighestRaise = findPlayerWithHighestRaise();
if ((prevPlayerAction == null) ||
(prevPlayerAction.getActionKind() != PlayerActionKind.RAISE) ||
(playerWithHighestRaise != null)) {
action.setActionKind(PlayerActionKind.CALL);
action.setChips(pot.getLastBet());
}
}
// In case of a RAISE with sum equals to the last bet, fix it to CALL.
else if ((action.getActionKind() == PlayerActionKind.RAISE) && (action.getChips().get() <= pot.getLastBet())) {
action.setActionKind(PlayerActionKind.CALL);
}
}
// If CALL arrived but there is no last bet, use big blind
else if ((action.getActionKind() == PlayerActionKind.CALL) && (action.getChips().get() < gameSettings.getBigBet())) {
action.setChips(gameSettings.getBigBet());
}
}
/**
* Use this method when it was the dealer turn and we are ready to open new card.
*
* @return Whether a new card was opened or not
*/
private boolean showNextCard() {
info(getId() + " - Showing next card.");
boolean isNewCardShown = false;
// If no flop, open the flop
if (!board.getFlop1().isPresent()) {
info(getId() + " - Showing flop.");
isNewCardShown = true;
deck.dropCard();
board.addCard(deck.popCard());
board.addCard(deck.popCard());
board.addCard(deck.popCard());
}
// Else, if there is no turn yet, open turn
else if (!board.hasTurn()) {
info(getId() + " - Showing turn.");
isNewCardShown = true;
deck.dropCard();
board.addCard(deck.popCard());
}
// Else, if there is no river yet, open river
else if (!board.hasRiver()) {
info(getId() + " - Showing river.");
isNewCardShown = true;
deck.dropCard();
board.addCard(deck.popCard());
}
return isNewCardShown;
}
/**
* Check if it is the very end round (after river) or if there is one active player only, to
* end a game and apply wins.
*/
private void applyWinIfNeeded() {
// When there is a single active player, or we arrived to the dealer after River is shown, end the round.
// Check also last bet cause if it is null, it means we have just shown a new card
Set<Player> involvedPlayers = players.getInvolvedPlayers();
int playersLeft = (int)involvedPlayers.stream().filter(player -> player.getChips().get() > 0).count();
if ((playersLeft <= 1) || board.hasRiver()) {
// In case there is more than single player in, but one player at most left with chips, make sure
// we open all of the cards in a board.
if ((involvedPlayers.size() > 1) && (playersLeft <= 1)) {
while (!board.hasRiver()) {
if (!showNextCard()) {
break;
}
}
}
// Sign that we are ready to restart a round, letting new players to join now
gameState.set(GameState.RESTART);
info(getId() + " - Apply winning.");
playerToEarnings = pot.applyWinning(involvedPlayers, board);
info(getId() + " - The winners: " + playerToEarnings);
// Log winners and listener about changes in chips due to win
playerToEarnings.forEach((playerId, earning) -> {
Player player = players.getPlayerById(playerId);
notifier.notifyPlayerChipsUpdated(player, earning.getSum());
gameLog.logAction(PlayerAction.builder()
.name(player.getName())
.chips(new Chips(earning.getSum()))
.handRank(earning.getHandRank())
.build());
});
// Wait for 10 seconds in background before starting a new round.
// We wait so clients can draw winning indications
ExecutorService service = Executors.newSingleThreadExecutor(new CustomThreadFactory("RoundLauncher-" + getGameHash()));
service.submit(() -> {
try {
Thread.sleep(TimeUnit.SECONDS.toMillis(10));
} catch (InterruptedException ignore) {
}
// Disconnect all players that have no chips to play with
players.getPlayers().stream().filter(p -> p.getChips().get() <= 0).forEach(this::removePlayer);
if (players.getPlayers().size() <= 1) {
playerToEarnings = null;
dealer = null;
gameState.set(GameState.READY);
} else {
// Move the dealer forward
dealer = players.getAvailablePlayer(players.indexOfPlayer(dealer) + 1);
players.setCurrentPlayerIndex(dealer.getPosition());
startRound();
}
service.shutdown();
});
}
}
/**
* This method is used after we have a {@link #dealer} player defined. We will take mandatory bets
* (small and big) from the players sitting after the dealer, and {@link #moveTurnForward()} to the next
* player (after big) that we are waiting for.<br/>
* Use this method when a game is first {@link #start() started} or when a round is over, and the dealer has been
* updated.
*/
private void startRound() {
gameState.set(GameState.STARTED);
info(getId() + " - Starting new round");
playerToEarnings = null;
// Make sure all players are marked as currently playing, as we are starting a new round.
players.markAllPlayersAsPlaying();
// Reset game log
gameLog.clear();
// Reset board
board.clear();
// Shuffle cards
deck.shuffle();
// Deal cards to players
dealCards();
// Current must to bet player is the one after the dealer. This player has to add small bet.
pot.clear();
lastActionKind = new ArrayDeque<>();
players.setCurrentPlayerIndex(players.indexOfPlayer(dealer) + 1);
smallBlindPlayer = players.getCurrentPlayer();
executePlayerAction(smallBlindPlayer, PlayerAction.builder().name(smallBlindPlayer.getName()).actionKind(PlayerActionKind.RAISE).chips(new Chips(gameSettings.getSmallBet())).build());
// Move to next player and take big bet from it.
bigBlindPlayer = players.getCurrentPlayer();
executePlayerAction(bigBlindPlayer, PlayerAction.builder().name(bigBlindPlayer.getName()).actionKind(PlayerActionKind.RAISE).chips(new Chips(gameSettings.getBigBet())).build());
// Now the game is officially started and we are waiting for the next player to play.
//moveTurnForward(); // executePlayerAction already moves turn forward
}
/**
* Call this method when starting a new round to deal cards to players.<br/>
* The first player to receive a card is the one after the dealer.<br/>
* We deal cards in an order like it was in the real world, where each player receive a card,
* in a circle, until all players own 2 cards.
*/
private void dealCards() {
players.getPlayers().forEach(p -> {
if (p.getHand() == null) {
p.setHand(new Hand());
} else {
p.getHand().clear();
}
});
int currPlayerIndex = players.indexOfPlayer(dealer) + 1;
for (int i = 0; i < players.getMaxAmountOfPlayers() * 2; i++) {
// It might be that some player exited while we are dealing cards, in this
// case we might deal too many cards. So protect this case and break the loop.
Player currPlayer = players.getPlayer(currPlayerIndex++);
if ((currPlayer != null) && (currPlayer.getHand().size() < 2)) {
currPlayer.getHand().addCard(deck.popCard());
}
}
}
/**
* A method used to move the turn from current player to the next playing one, and init the time started
* of this player, to count the time it takes for this player to start.
*/
private void moveTurnForward() {
Player newPlayer = players.nextPlayer();
info(getId() + " - Moving turn to next player: " + newPlayer);
if (gameSettings.isNetwork()) {
playerTurnTimer.startOrReset();
}
}
/**
* This event is raised in case of network game, and it tells that the current player has
* ran out of time. In this case, we force the current player to fold and move forward to next player.
*/
private void onPlayerTurnTimeout() {
info(getId() + " - Player turn timeout occurred. [player=" + players.getCurrentPlayer() + "]");
executePlayerAction(players.getCurrentPlayer(),
PlayerAction.builder().name(players.getCurrentPlayer().getName()).actionKind(PlayerActionKind.FOLD).build());
}
/**
* Stop this game engine.<br/>
* We will stop and if there are bets, we return them back to the players.
*/
public void stop() {
// Do this in case game is not already stopped
if (gameState.compareAndSet(GameState.READY, GameState.STOPPED) ||
gameState.compareAndSet(GameState.STARTED, GameState.STOPPED) ||
gameState.compareAndSet(GameState.RESTART, GameState.STOPPED)) {
info(getId() + " - Stopping game.");
gameState.set(GameState.STOPPED);
if (playerTurnTimer != null) {
playerTurnTimer.stop();
}
gameLog.clear();
players.clear();
chat.clear();
chat.getUsers().clear();
board.clear();
dealer = null;
lastActionKind = null;
// In case there are pots, return the chips back to their owners.
Map<Player, Long> pots = pot.getPlayerPots();
pots.forEach((player, chips) -> {
player.getChips().add(chips);
// Update listener about update of chips
notifier.notifyPlayerChipsUpdated(player, chips);
});
pot.clear();
}
}
/**
* Tests whether this game is currently active. A game does not accept new players when it is active.
* @return Whether current game engine is active (during a round) or not.
*/
@JsonIgnore
public boolean isActive() {
return gameState.get() == GameState.STARTED;
}
/**
* @return Current {@link GameEngine.GameState game state}
*/
public GameState getGameState() {
return gameState.get();
}
/**
* An enum representing current game engine's state:
* <ul>
* <li>{@link #READY}</li>
* <li>{@link #RESTART}</li>
* <li>{@link #STARTED}</li>
* <li>{@link #STOPPED}</li>
* </ul>
*/
public enum GameState {
/**
* Game is ready to be started. This state is set when a game engine is created, and before it is started at {@link GameEngine#start()}.
*/
READY,
/**
* When a round is over and we have applied winnings, the game is in RESTART state, allowing new players to join.
*/
RESTART,
/**
* Game is started and is about to move to one of the other states.
*/
STARTED,
/**
* Game was stopped by calling {@link GameEngine#stop()}
*/
STOPPED
}
/**
* Use this as a listener to player updates.<br/>
* We expose this functionality to let an outside class to save players to disk upon updates,
* to implement persistence.
*/
@FunctionalInterface
public interface PlayerUpdateListener {
/**
* This event is raised whenever player's chips are updated during a game
* @param player The player with the up to date chips
* @param chips The chips value that was modified. Can be negative when player loses chips
*/
void onPlayerChipsUpdated(Player player, long chips);
}
/**
* A class to notify listener asynchronously, and avoid of blocking game engine.<br/>
* The listener might do IO operations that are not necessarily blocking.
*/
private class PlayerUpdateNotifier {
private final ExecutorService executor;
/**
* Constructs a new {@link PlayerUpdateNotifier}
*/
public PlayerUpdateNotifier() {
executor = Executors.newSingleThreadExecutor(new CustomThreadFactory("PlayerUpdateNotifier-" + getGameHash()));
}
/**
* Notify listener, asynchronously.
* @param player The player with the up to date chips
* @param chips The chips value that was modified. Can be negative when player loses chips
*/
void notifyPlayerChipsUpdated(Player player, long chips) {
executor.submit(() -> listener.onPlayerChipsUpdated(player, chips));
}
}
}