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
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
use async_trait::async_trait;
use rand::rngs::SmallRng;
use rand::{RngExt, SeedableRng, rng};
use std::sync::atomic::{AtomicUsize, Ordering};
use tracing::{instrument, trace};
use crate::{
arena::{
action::AgentAction,
game_state::{GameState, Round},
},
core::Hand,
holdem::MonteCarloGame,
};
use super::{Agent, AgentGenerator};
#[derive(Debug, Clone)]
pub struct RandomAgent {
name: String,
percent_fold: Vec<f64>,
percent_call: Vec<f64>,
rng: SmallRng,
}
impl RandomAgent {
pub fn new(name: impl Into<String>, percent_fold: Vec<f64>, percent_call: Vec<f64>) -> Self {
Self::with_rng(
name,
percent_fold,
percent_call,
SmallRng::from_rng(&mut rng()),
)
}
pub fn new_with_seed(
name: impl Into<String>,
percent_fold: Vec<f64>,
percent_call: Vec<f64>,
seed: u64,
) -> Self {
Self::with_rng(
name,
percent_fold,
percent_call,
SmallRng::seed_from_u64(seed),
)
}
fn with_rng(
name: impl Into<String>,
percent_fold: Vec<f64>,
percent_call: Vec<f64>,
rng: SmallRng,
) -> Self {
Self {
name: name.into(),
percent_call,
percent_fold,
rng,
}
}
}
impl Default for RandomAgent {
fn default() -> Self {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let idx = COUNTER.fetch_add(1, Ordering::Relaxed);
Self::new(
format!("RandomAgent-default-{idx}"),
vec![0.25, 0.30, 0.50],
vec![0.5, 0.6, 0.45],
)
}
}
#[async_trait]
impl Agent for RandomAgent {
#[instrument(level = "trace", skip(self, game_state), fields(agent_name = %self.name))]
async fn act(self: &mut RandomAgent, _id: u128, game_state: &GameState) -> AgentAction {
let round_data = &game_state.round_data;
let player_bet = round_data.current_player_bet();
let player_stack = game_state.stacks[round_data.to_act_idx];
let curr_bet = round_data.bet;
let raise_count = round_data.total_raise_count;
// The min we can bet when not calling is the current bet plus the min raise
// However it's possible that would put the player all in.
let min = (curr_bet + round_data.min_raise).min(player_bet + player_stack);
// The max we can bet going all in.
//
// However we don't want to overbet too early
// so cap to a value representing how much we
// could get everyone to put into the pot by
// calling a pot sized bet (plus a little more for spicyness)
//
// That could be the same as the min
let pot_value = (round_data.num_players_need_action() as f32 + 1.0) * game_state.total_pot;
let max = (player_bet + player_stack).min(pot_value).max(min);
// We shouldn't fold when checking is an option.
let can_fold = curr_bet > player_bet;
// As there are more raises we should look deeper
// into the fold percentaages that the user gave us
let fold_idx = raise_count.min((self.percent_fold.len() - 1) as u8) as usize;
let percent_fold = self.percent_fold.get(fold_idx).map_or_else(|| 1.0, |v| *v);
// As there are more raises we should look deeper
// into the call percentages that the user gave us
let call_idx = raise_count.min((self.percent_call.len() - 1) as u8) as usize;
let percent_call = self.percent_call.get(call_idx).map_or_else(|| 1.0, |v| *v);
// Now do the action decision
let action = if can_fold && self.rng.random_bool(percent_fold) {
// We can fold and the rng was in favor so fold.
AgentAction::Fold
} else if self.rng.random_bool(percent_call) {
// We're calling, which is the same as betting the same as the current.
// Luckily for us the simulation will take care of us if this puts us all in.
AgentAction::Call
} else if max > min {
// If there's some range and the rng didn't choose another option. So bet some
// amount.
AgentAction::Bet(self.rng.random_range(min..max))
} else {
AgentAction::Bet(max)
};
trace!(?action, raise_count, can_fold, "RandomAgent decision");
action
}
fn name(&self) -> &str {
&self.name
}
}
#[derive(Debug, Clone)]
pub struct RandomAgentGenerator {
name: Option<String>,
percent_fold: Vec<f64>,
percent_call: Vec<f64>,
/// Optional base seed. When set, each generated `RandomAgent` is
/// seeded from `base.wrapping_add(player_idx as u64)` so that
/// repeated runs of the same competition produce bit-identical
/// decisions. When `None` the agents draw fresh OS entropy.
seed: Option<u64>,
}
impl RandomAgentGenerator {
pub fn new(percent_fold: Vec<f64>, percent_call: Vec<f64>) -> Self {
Self {
name: None,
percent_fold,
percent_call,
seed: None,
}
}
/// Create a generator whose produced agents are seeded from a
/// deterministic base. Each player's `RandomAgent` is seeded from
/// `seed.wrapping_add(player_idx as u64)`.
pub fn seeded(percent_fold: Vec<f64>, percent_call: Vec<f64>, seed: u64) -> Self {
Self {
name: None,
percent_fold,
percent_call,
seed: Some(seed),
}
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
/// Set (or clear) the seed used to construct generated agents.
pub fn with_seed(mut self, seed: Option<u64>) -> Self {
self.seed = seed;
self
}
fn resolve_name(&self, player_idx: usize) -> String {
self.name
.clone()
.unwrap_or_else(|| format!("RandomAgent-{player_idx}"))
}
}
impl AgentGenerator for RandomAgentGenerator {
fn generate(&self, player_idx: usize, _game_state: &GameState) -> Box<dyn Agent> {
let name = self.resolve_name(player_idx);
match self.seed {
Some(base) => Box::new(RandomAgent::new_with_seed(
name,
self.percent_fold.clone(),
self.percent_call.clone(),
base.wrapping_add(player_idx as u64),
)),
None => Box::new(RandomAgent::new(
name,
self.percent_fold.clone(),
self.percent_call.clone(),
)),
}
}
}
impl Default for RandomAgentGenerator {
fn default() -> Self {
Self::new(vec![0.25, 0.30, 0.50], vec![0.5, 0.6, 0.45])
}
}
/// This is an `Agent` implementation that chooses random actions in some
/// relation to the value of the pot. It assumes that it's up against totally
/// random cards for each hand then estimates the value of the pot for what
/// range of values to bet.
///
/// The percent_call is the percent that the agent will not bet even though it
/// values the pot above the current bet or 0 if it's the first to act.
#[derive(Debug, Clone)]
pub struct RandomPotControlAgent {
name: String,
percent_call: Vec<f64>,
rng: SmallRng,
}
impl RandomPotControlAgent {
pub fn new(name: impl Into<String>, percent_call: Vec<f64>) -> Self {
Self::with_rng(name, percent_call, SmallRng::from_rng(&mut rng()))
}
pub fn new_with_seed(name: impl Into<String>, percent_call: Vec<f64>, seed: u64) -> Self {
Self::with_rng(name, percent_call, SmallRng::seed_from_u64(seed))
}
fn with_rng(name: impl Into<String>, percent_call: Vec<f64>, rng: SmallRng) -> Self {
Self {
name: name.into(),
percent_call,
rng,
}
}
fn expected_pot(&self, game_state: &GameState) -> f32 {
if game_state.round == Round::Preflop {
(3.0 * game_state.big_blind).max(game_state.total_pot)
} else {
game_state.total_pot
}
}
fn clean_hands(&self, game_state: &GameState) -> Vec<Hand> {
let mut default_hand = Hand::new();
// Copy the board into the default hand
default_hand.extend(game_state.board.iter().cloned());
let to_act_idx = game_state.to_act_idx();
game_state
.hands
.clone()
.into_iter()
.enumerate()
.map(|(hand_idx, hand)| {
if hand_idx == to_act_idx {
hand
} else {
default_hand
}
})
.collect()
}
fn monte_carlo_based_action(
&mut self,
game_state: &GameState,
mut monte: MonteCarloGame,
) -> AgentAction {
// We play some trickery to make sure that someone will call before there's
// money in the pot
let expected_pot = self.expected_pot(game_state);
// run the monte carlo simulation a lot of times to see who would win with the
// knowledge that we have. Keeping in mind that we have no information and are
// actively guessing no hand ranges at all. So this is likely a horrible way to
// estimate hand strength
//
// Then truncate the values to f32.
let values: Vec<f32> = monte.estimate_equity(1_000).into_iter().collect();
let to_act_idx = game_state.to_act_idx();
// How much do I actually value the pot right now?
let my_value = values.get(to_act_idx).unwrap_or(&0.0_f32) * expected_pot;
// What have we already put into the pot for the round?
let bet_already = game_state.current_round_player_bet(to_act_idx);
// How much total is required to continue
let to_call = game_state.current_round_bet();
// What more is needed from us
let needed = to_call - bet_already;
// If we don't value the pot at what's required then just bail out.
// But only fold if there's actually something to call (otherwise check)
if my_value < needed && needed > 0.0 {
AgentAction::Fold
} else if needed <= 0.0 {
// Nothing to call - just check
AgentAction::Bet(to_call)
} else {
self.random_action(game_state, my_value)
}
}
fn random_action(&mut self, game_state: &GameState, max_value: f32) -> AgentAction {
// Use the number of bets to determine the call percentage
let round_data = &game_state.round_data;
let raise_count = round_data.total_raise_count;
let call_idx = raise_count.min((self.percent_call.len() - 1) as u8) as usize;
let percent_call = self.percent_call.get(call_idx).map_or_else(|| 1.0, |v| *v);
// Check player's stack to determine valid bet range
let player_stack = game_state.current_player_stack();
let player_bet_this_round = game_state.current_round_current_player_bet();
let max_total_bet = player_bet_this_round + player_stack;
if self.rng.random_bool(percent_call) {
AgentAction::Bet(round_data.bet)
} else {
// Even though this is a random action try not to under min raise
let min_raise = round_data.min_raise;
// The minimum valid raise amount
let min_raise_total = round_data.bet + min_raise;
// Check if we can even afford the min raise
if max_total_bet < min_raise_total {
// Can't afford min raise - either go all-in or call
if max_total_bet > round_data.bet {
// All-in is our only raise option
AgentAction::AllIn
} else {
// Just call
AgentAction::Bet(round_data.bet)
}
} else {
// We can afford to raise - pick a random amount
let high = max_value
.max(min_raise_total + min_raise)
.min(max_total_bet);
let bet_value = self
.rng
.random_range(min_raise_total..high.max(min_raise_total + 1.0));
AgentAction::Bet(bet_value)
}
}
}
}
#[async_trait]
impl Agent for RandomPotControlAgent {
#[instrument(level = "trace", skip(self, game_state), fields(agent_name = %self.name))]
async fn act(&mut self, _id: u128, game_state: &GameState) -> AgentAction {
// We don't want to cheat.
// So replace all the hands but our own
let clean_hands = self.clean_hands(game_state);
// Now check if we can simulate that
let action = if let Ok(monte) = MonteCarloGame::new(clean_hands) {
self.monte_carlo_based_action(game_state, monte)
} else {
// If we can't do monte carlo, check if we can fold or need to check
let to_call = game_state.current_round_bet();
let bet_already = game_state.current_round_current_player_bet();
let needed = to_call - bet_already;
if needed > 0.0 {
AgentAction::Fold
} else {
// Nothing to call - just check
AgentAction::Bet(to_call)
}
};
trace!(?action, "RandomPotControlAgent decision");
action
}
fn name(&self) -> &str {
&self.name
}
}
#[cfg(test)]
mod tests {
use crate::arena::{
HoldemSimulationBuilder,
test_util::{assert_valid_game_state, assert_valid_round_data},
};
use super::*;
use crate::arena::GameStateBuilder;
#[tokio::test(flavor = "current_thread")]
async fn test_random_generator_produces_named_caller() {
let generator = RandomAgentGenerator::new(vec![0.0], vec![1.0]);
let game_state = GameStateBuilder::new()
.num_players_with_stack(2, 100.0)
.blinds(10.0, 5.0)
.build()
.unwrap();
let mut agent = generator.generate(3, &game_state);
assert_eq!(agent.name(), "RandomAgent-3");
match agent.act(0, &game_state).await {
AgentAction::Call => {}
action => panic!("Expected forced call, got {:?}", action),
}
}
/// Regression test for M2: `RandomAgentGenerator::seeded` must
/// produce deterministic agents — two generators built with the
/// same seed must generate agents whose action streams are
/// identical across runs.
#[tokio::test(flavor = "current_thread")]
async fn test_seeded_random_agent_generator_is_deterministic() {
let game_state = GameStateBuilder::new()
.num_players_with_stack(2, 500.0)
.blinds(10.0, 5.0)
.build()
.unwrap();
async fn collect_actions(game_state: &GameState) -> Vec<AgentAction> {
let generator =
RandomAgentGenerator::seeded(vec![0.1, 0.2], vec![0.4, 0.3], 0xdeadbeef);
let mut agent = generator.generate(1, game_state);
let mut actions = Vec::new();
for i in 0..64u128 {
actions.push(agent.act(i, game_state).await);
}
actions
}
let run_a = collect_actions(&game_state).await;
let run_b = collect_actions(&game_state).await;
assert_eq!(run_a, run_b);
}
/// Different player indices must still produce independent streams
/// even when seeded from the same base.
#[tokio::test(flavor = "current_thread")]
async fn test_seeded_random_agent_generator_differs_across_players() {
let game_state = GameStateBuilder::new()
.num_players_with_stack(2, 500.0)
.blinds(10.0, 5.0)
.build()
.unwrap();
let generator = RandomAgentGenerator::seeded(vec![0.1, 0.2], vec![0.4, 0.3], 0xdeadbeef);
let mut p0 = generator.generate(0, &game_state);
let mut p1 = generator.generate(1, &game_state);
let mut actions0 = Vec::new();
let mut actions1 = Vec::new();
for i in 0..32u128 {
actions0.push(p0.act(i, &game_state).await);
actions1.push(p1.act(i, &game_state).await);
}
assert_ne!(actions0, actions1);
}
#[test]
fn test_random_generator_uses_custom_name() {
let generator = RandomAgentGenerator::new(vec![0.0], vec![1.0]).with_name("RandomHero");
let game_state = GameStateBuilder::new()
.num_players_with_stack(2, 20.0)
.blinds(10.0, 5.0)
.build()
.unwrap();
let agent = generator.generate(7, &game_state);
assert_eq!(agent.name(), "RandomHero");
}
#[tokio::test]
async fn test_random_five_nl() {
let rng = SmallRng::from_rng(&mut rng());
let stacks = vec![100.0; 5];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let agents: Vec<Box<dyn Agent>> = (0..5)
.map(|idx| {
Box::new(RandomAgent::new(
format!("RandomAgent-{idx}"),
vec![0.25, 0.30, 0.50],
vec![0.5, 0.6, 0.45],
)) as Box<dyn Agent>
})
.collect();
// The simulation deals hole cards itself; seeding them here too would
// leave each player holding four hole cards (and a nine-card showdown).
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.build_with_rng(rng)
.unwrap();
sim.run().await;
let min_stack = sim
.game_state
.stacks
.clone()
.into_iter()
.reduce(f32::min)
.unwrap();
let max_stack = sim
.game_state
.stacks
.clone()
.into_iter()
.reduce(f32::max)
.unwrap();
assert_ne!(min_stack, max_stack, "There should have been some betting.");
assert_valid_round_data(&sim.game_state.round_data);
assert_valid_game_state(&sim.game_state);
}
#[tokio::test]
async fn test_five_pot_control() {
let stacks = vec![100.0; 5];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let agents: Vec<Box<dyn Agent>> = (0..5)
.map(|idx| {
Box::new(RandomPotControlAgent::new(
format!("RandomPotControl-{idx}"),
vec![0.3],
)) as Box<dyn Agent>
})
.collect();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.build()
.unwrap();
sim.run().await;
let min_stack = sim
.game_state
.stacks
.clone()
.into_iter()
.reduce(f32::min)
.unwrap();
let max_stack = sim
.game_state
.stacks
.clone()
.into_iter()
.reduce(f32::max)
.unwrap();
assert_ne!(min_stack, max_stack, "There should have been some betting.");
assert_valid_round_data(&sim.game_state.round_data);
assert_valid_game_state(&sim.game_state);
}
#[tokio::test]
async fn test_random_agents_no_fold_get_all_rounds() {
let stacks = vec![100.0; 5];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
let agents: Vec<Box<dyn Agent>> = (0..5)
.map(|idx| {
Box::new(RandomAgent::new(
format!("AggroRandom-{idx}"),
vec![0.0],
vec![0.75],
)) as Box<dyn Agent>
})
.collect();
let mut sim = HoldemSimulationBuilder::default()
.agents(agents)
.game_state(game_state)
.build()
.unwrap();
sim.run().await;
assert!(sim.game_state.is_complete());
assert_valid_game_state(&sim.game_state);
}
#[test]
fn test_random_agent_name_returns_name() {
let agent = RandomAgent::new("TestAgent", vec![0.5], vec![0.5]);
// Test that name() returns the actual name, not empty string
assert_eq!(agent.name(), "TestAgent");
assert!(!agent.name().is_empty());
}
#[test]
fn test_random_pot_control_agent_name_returns_name() {
let agent = RandomPotControlAgent::new("PotControl", vec![0.5]);
// Test that name() returns the actual name, not empty string
assert_eq!(agent.name(), "PotControl");
assert!(!agent.name().is_empty());
}
#[test]
fn test_random_agent_expected_pot_preflop() {
let agent = RandomPotControlAgent::new("Test", vec![0.5]);
let mut game_state = GameStateBuilder::new()
.stacks(vec![100.0, 100.0])
.blinds(10.0, 5.0)
.build()
.unwrap();
// Set to preflop round
game_state.round = Round::Preflop;
game_state.total_pot = 15.0; // SB + BB
// In preflop: max(3.0 * big_blind, total_pot) = max(30.0, 15.0) = 30.0
let expected = agent.expected_pot(&game_state);
// Preflop expected pot should be max(3.0 * big_blind, total_pot)
// With big_blind=10 and total_pot=15, that's max(30, 15) = 30
assert!(
(expected - 30.0).abs() < 0.01,
"expected_pot should be 30.0 in preflop with small pot, got {}",
expected
);
}
#[test]
fn test_random_agent_expected_pot_postflop() {
let agent = RandomPotControlAgent::new("Test", vec![0.5]);
let mut game_state = GameStateBuilder::new()
.stacks(vec![100.0, 100.0])
.blinds(10.0, 5.0)
.build()
.unwrap();
// Move to flop
game_state.round = Round::Flop;
game_state.total_pot = 50.0;
let expected = agent.expected_pot(&game_state);
// Post-flop: just returns total_pot
assert!(
(expected - 50.0).abs() < 0.01,
"expected_pot post-flop should equal total_pot (50.0), got {}",
expected
);
}
#[test]
fn test_random_agent_clean_hands_preserves_own_hand() {
let agent = RandomPotControlAgent::new("Test", vec![0.5]);
let mut game_state = GameStateBuilder::new()
.stacks(vec![100.0, 100.0])
.blinds(10.0, 5.0)
.build()
.unwrap();
// Set up specific cards - use different cards for hands and board
let cards: Vec<_> = crate::core::Deck::default().into_iter().take(7).collect();
game_state.hands[0].insert(cards[0]); // Player 0's hole cards
game_state.hands[0].insert(cards[1]);
game_state.hands[1].insert(cards[2]); // Player 1's hole cards
game_state.hands[1].insert(cards[3]);
// Add 3 board cards so the cleaned hand count differs from hole cards
game_state.board.push(cards[4]);
game_state.board.push(cards[5]);
game_state.board.push(cards[6]);
let to_act = game_state.to_act_idx();
let clean = agent.clean_hands(&game_state);
// The to_act player should have their original hand (2 cards)
assert_eq!(
clean[to_act].count(),
game_state.hands[to_act].count(),
"Acting player should keep their original hand"
);
// The acting player's hand should contain their actual hole cards
assert!(
clean[to_act].contains(&cards[0]),
"Acting player's hand should contain their first hole card"
);
// Other players should have only board cards (3 cards), not their hole cards (2 cards)
for (idx, hand) in clean.iter().enumerate() {
if idx != to_act {
// Cleaned hand should have board cards (3), not hole cards (2)
assert_eq!(
hand.count(),
3,
"Non-acting player's cleaned hand should have 3 board cards"
);
// Should NOT contain the original hole cards
assert!(
!hand.contains(&cards[2]) && !hand.contains(&cards[3]),
"Non-acting player's cleaned hand should not contain their hole cards"
);
}
}
}
#[tokio::test(flavor = "current_thread")]
async fn test_random_agent_can_fold_logic() {
// When current bet > player bet, should be able to fold
let mut agent = RandomAgent::new("FoldTest", vec![1.0], vec![0.0]); // 100% fold
let mut game_state = GameStateBuilder::new()
.stacks(vec![100.0, 100.0])
.blinds(10.0, 5.0)
.build()
.unwrap();
// Set up a situation where the player faces a bet
// round_data.bet = current bet to call
// player_bets[to_act] = what this player has already bet this round
game_state.round_data.bet = 10.0; // There's a bet of 10 to call
game_state.round_data.player_bet[0] = 5.0; // Player has only bet 5 (like SB)
// can_fold = curr_bet (10) > player_bet (5) = true
// With 100% fold probability, should fold
let action = agent.act(0, &game_state).await;
assert!(
matches!(action, AgentAction::Fold),
"With 100% fold when can_fold=true, should fold. Got {:?}",
action
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_random_agent_cannot_fold_when_checking() {
// When current bet == player bet, should not fold (can check)
let mut agent = RandomAgent::new("CheckTest", vec![1.0], vec![0.0]); // 100% fold
let mut game_state = GameStateBuilder::new()
.stacks(vec![100.0, 100.0])
.blinds(10.0, 5.0)
.build()
.unwrap();
// Simulate BB position where bet is matched (nothing to call)
game_state.round_data.bet = 0.0;
game_state.round_data.player_bet[0] = 0.0;
game_state.stacks[0] = 100.0;
// can_fold = curr_bet (0) > player_bet (0) = false
let action = agent.act(0, &game_state).await;
// With no bet to call (bet=0), can_fold=false, so shouldn't fold
// Should call or bet
assert!(
!matches!(action, AgentAction::Fold),
"Should not fold when can check. Got {:?}",
action
);
}
#[tokio::test]
async fn test_random_agent_min_calculation() {
// Test that min bet is calculated correctly using addition
let agent = RandomAgent::new("MinTest", vec![0.0], vec![0.0]);
let game_state = GameStateBuilder::new()
.stacks(vec![50.0, 50.0])
.blinds(10.0, 5.0)
.build()
.unwrap();
// min = (curr_bet + min_raise).min(player_bet + player_stack)
// curr_bet = 10 (big blind)
// min_raise = 10 (big blind)
// So min = 20 unless that would be all-in
// player_bet = 5 (SB), player_stack = 45
// player_bet + player_stack = 50
// min(20, 50) = 20
// The agent uses this internally; we can verify the game completes
let agents: Vec<Box<dyn Agent>> = vec![
Box::new(agent),
Box::new(RandomAgent::new("Other", vec![0.0], vec![1.0])),
];
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.build()
.unwrap();
sim.run().await;
assert!(sim.game_state.is_complete());
}
#[tokio::test]
async fn test_random_pot_control_needed_calculation() {
// Test the subtraction logic in monte_carlo_based_action
let agent = RandomPotControlAgent::new("NeededTest", vec![1.0]); // Always call
// Set up a game state where we can verify needed = to_call - bet_already
let game_state = GameStateBuilder::new()
.stacks(vec![100.0, 100.0])
.blinds(10.0, 5.0)
.build()
.unwrap();
// After forced bets: SB has bet 5, BB has bet 10
// If SB acts first, to_call=10, bet_already=5, needed=5
// With - : 10 - 5 = 5 (correct)
// With + : 10 + 5 = 15 (wrong)
let rng = SmallRng::from_rng(&mut rng());
let agents: Vec<Box<dyn Agent>> = vec![
Box::new(agent),
Box::new(RandomPotControlAgent::new("Other", vec![1.0])),
];
// The simulation deals hole cards itself; seeding them here too would
// leave each player holding four hole cards (and a nine-card showdown).
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.build_with_rng(rng)
.unwrap();
sim.run().await;
assert!(sim.game_state.is_complete());
}
#[tokio::test]
async fn test_random_pot_control_max_total_bet_calculation() {
// Test that max_total_bet = player_bet_this_round + player_stack uses addition
let agent = RandomPotControlAgent::new("MaxBetTest", vec![0.0]); // Never call, always try bet
let game_state = GameStateBuilder::new()
.stacks(vec![100.0, 100.0])
.blinds(10.0, 5.0)
.build()
.unwrap();
// player_bet_this_round + player_stack = 5 + 95 = 100
// With +: correct
// With *: 5 * 95 = 475 (wrong, exceeds stack)
let agents: Vec<Box<dyn Agent>> = vec![
Box::new(agent),
Box::new(RandomPotControlAgent::new("Other", vec![1.0])),
];
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.build()
.unwrap();
sim.run().await;
// Game should complete without panicking due to invalid bet calculations
assert!(sim.game_state.is_complete());
}
#[tokio::test]
async fn test_random_pot_control_high_calculation() {
// Test the addition in high = max_value.max(min_raise_total + min_raise).min(max_total_bet)
let agent = RandomPotControlAgent::new("HighTest", vec![0.0]); // Never call
// Create a game with specific stack sizes to exercise the calculation
let game_state = GameStateBuilder::new()
.stacks(vec![200.0, 200.0])
.blinds(10.0, 5.0)
.build()
.unwrap();
let agents: Vec<Box<dyn Agent>> = vec![
Box::new(agent),
Box::new(RandomPotControlAgent::new("Other", vec![0.0])),
];
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.build()
.unwrap();
sim.run().await;
assert!(sim.game_state.is_complete());
}
#[tokio::test]
async fn test_random_pot_control_short_stack_cannot_raise() {
// Test the comparison: max_total_bet < min_raise_total
let agent = RandomPotControlAgent::new("ShortStack", vec![0.0]); // Never call
// Small stack that can't make minimum raise
let game_state = GameStateBuilder::new()
.stacks(vec![15.0, 100.0])
.blinds(10.0, 5.0)
.build()
.unwrap();
// Player 0 has 15 total
// After posting SB (5), has 10 left
// To min-raise, would need: current_bet (10) + min_raise (10) = 20
// But max_total_bet = player_bet_this_round (5) + stack (10) = 15
// 15 < 20, so cannot min-raise
let agents: Vec<Box<dyn Agent>> = vec![
Box::new(agent),
Box::new(RandomPotControlAgent::new("BigStack", vec![1.0])),
];
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.build()
.unwrap();
sim.run().await;
assert!(sim.game_state.is_complete());
}
#[tokio::test]
async fn test_random_agent_pot_value_multiplication() {
// Test: pot_value = (num_players + 1.0) * total_pot
// With *: correct
// With +: (5 + 1.0) + 15 = 21 (wrong)
// With /: (5 + 1.0) / 15 = 0.4 (wrong)
let stacks = vec![100.0; 5];
let game_state = GameStateBuilder::new()
.stacks(stacks)
.blinds(10.0, 5.0)
.build()
.unwrap();
// 5 players, pot = 15 (BB 10 + SB 5)
// pot_value = (5 + 1.0) * 15 = 90
let agents: Vec<Box<dyn Agent>> = (0..5)
.map(|idx| {
Box::new(RandomAgent::new(
format!("Agent{idx}"),
vec![0.0],
vec![0.0],
)) as Box<dyn Agent>
})
.collect();
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.build()
.unwrap();
sim.run().await;
assert!(sim.game_state.is_complete());
}
#[tokio::test]
async fn test_random_agent_raise_count_index_calculation() {
// Test: fold_idx = raise_count.min((self.percent_fold.len() - 1) as u8) as usize
// The subtraction is important: len() - 1 gives last valid index
let agent = RandomAgent::new(
"IndexTest",
vec![0.5, 0.75, 0.9], // 3 elements, indices 0, 1, 2
vec![0.5],
);
// With many raises, should use index 2 (last), not overflow
assert_eq!(agent.percent_fold.len(), 3);
// Test with simulation - should not panic
let game_state = GameStateBuilder::new()
.stacks(vec![100.0, 100.0])
.blinds(10.0, 5.0)
.build()
.unwrap();
let agents: Vec<Box<dyn Agent>> = vec![
Box::new(agent),
Box::new(RandomAgent::new("Aggro", vec![0.0], vec![0.0])), // Always raises
];
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.build()
.unwrap();
sim.run().await;
assert!(sim.game_state.is_complete());
}
#[tokio::test]
async fn test_pot_control_my_value_multiplication() {
// Test: my_value = equity * expected_pot
// This tests that * is used, not + or /
let agent = RandomPotControlAgent::new("ValueTest", vec![0.5]);
// With multiplication: equity (0.5) * pot (50) = 25
// With addition: 0.5 + 50 = 50.5 (wrong)
// With division: 0.5 / 50 = 0.01 (wrong)
let mut game_state = GameStateBuilder::new()
.stacks(vec![100.0, 100.0])
.blinds(10.0, 5.0)
.build()
.unwrap();
game_state.total_pot = 50.0;
let rng = SmallRng::from_rng(&mut rng());
let agents: Vec<Box<dyn Agent>> = vec![
Box::new(agent),
Box::new(RandomPotControlAgent::new("Other", vec![0.5])),
];
// The simulation deals hole cards itself; seeding them here too would
// leave each player holding four hole cards (and a nine-card showdown).
let mut sim = HoldemSimulationBuilder::default()
.game_state(game_state)
.agents(agents)
.build_with_rng(rng)
.unwrap();
sim.run().await;
assert!(sim.game_state.is_complete());
}
}