Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions poker-texas-hold-em/contract/Scarb.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
cairo-version = "=2.10.1"
name = "poker"
version = "1.1.0"
edition = "2023_10"

[cairo]
sierra-replace-ids = true
Expand Down
1 change: 1 addition & 0 deletions poker-texas-hold-em/contract/src/models/game.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub struct GameParams {
kicker_split: bool,
min_amount_of_chips: u256,
blind_spacing: u16,
bet_spacing: u256,
showdown_type: ShowdownType,
}

Expand Down
114 changes: 96 additions & 18 deletions poker-texas-hold-em/contract/src/systems/actions.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -292,26 +292,38 @@ pub mod actions {
no_of_chips > game_current_bet, "Raise amount is less than the game's current bet.",
);

// Validate bet spacing - raise amount must be in multiples of bet_spacing @kaylahray
let bet_spacing = params.bet_spacing;
// Only the increment after current_bet needs to be multiple of bet_spacing. @kaylahray
let raise_delta = no_of_chips - game_current_bet;
assert(raise_delta % bet_spacing.into() == 0, 'Invalid raise spacing');

// adjust this pot accordingly
let mut game_pot = *game_pots.at(game_pots.len() - 1);

let amount_to_call = game_current_bet - player.current_bet;
let total_required = amount_to_call + no_of_chips;
if game_pot == params.small_blind.into() {
assert!(
no_of_chips > game_pot * 2, "Raise amount should be > twice the small blind.",
);
assert!(no_of_chips > game_pot * 2, "Raise must be > 2x blind.");
}

assert!(no_of_chips > 0, "Raise amount must be greater than zero.");
assert!(player.chips >= total_required, "You don't have enough chips to raise.");
assert!(no_of_chips > 0, "Raise must be > 0.");
assert!(player.chips >= total_required, "Insufficient chips to raise.");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see good English. Welldone


if !self.adjust_stake(game_id, amount_to_call, ref player) {
player.chips -= total_required;
player.current_bet += total_required;
game_pot += total_required;
}

game_current_bet = player.current_bet;
// @Kaylahray 👇
world
.write_member(
Model::<Game>::ptr_from_keys(game_id),
selector!("highest_staker"),
Option::Some(player.id),
);

let mut updated_game_pots: Array<u256> = ArrayTrait::new();
let mut i = 0;
Expand All @@ -328,6 +340,7 @@ pub mod actions {
self.after_play(player.id);
}


/// @dub_zn
fn all_in(ref self: ContractState) {
let mut world = self.world_default();
Expand All @@ -338,10 +351,28 @@ pub mod actions {
let amount = player.chips;

let cb = selector!("current_bet");
let mut game_current_bet = world.read_member(Model::<Game>::ptr_from_keys(game_id), cb);
let game_current_bet = world.read_member(Model::<Game>::ptr_from_keys(game_id), cb);

// Update player state for all-in @kaylahray
player.current_bet += amount;
player.chips = 0;

// @kaylahray Set highest_staker if this all-in creates new highest bet
if player.current_bet > game_current_bet {
world
.write_member(
Model::<Game>::ptr_from_keys(game_id),
selector!("highest_staker"),
Option::Some(player.id),
);
world.write_member(Model::<Game>::ptr_from_keys(game_id), cb, player.current_bet);
}

// Handle side pot creation if needed
if amount < game_current_bet {
self.adjust_pot(game_id, ref player, game_current_bet);
}

world.write_model(@player);
self.after_play(player.id);
}
Expand Down Expand Up @@ -797,25 +828,15 @@ pub mod actions {
let current_index: usize = OptionTrait::unwrap(current_index_option);

// Update game state with the player's action

// TODO: Crosscheck after_play, and adjust... may not be needed.
if player.current_bet > game.current_bet {
game.current_bet = player.current_bet; // Raise updates the current bet
game.highest_staker = Option::Some(caller);
} else if let Option::Some(highest_staker) = game.highest_staker {
if highest_staker == caller {
// bet has gone round
if game.community_cards.len() == 5 {
game.showdown = true;
} else {
game.community_dealing = true;
}
}
}

// Write player state to storage BEFORE checking betting round completion
world.write_model(@player);

// Determine the next active player or resolve the round
// Determine the next active player
let next_player_option: Option<ContractAddress> = self
.find_next_active_player(@game.players, current_index, @world);

Expand All @@ -824,6 +845,12 @@ pub mod actions {
game.showdown = true;
} else {
game.next_player = next_player_option;

// Check if betting round is complete (more gas efficient) @kaylahray
if self.is_betting_round_complete(@game, @world) {
// Reset betting state efficiently
self.reset_betting_round(game_id, ref game, ref world);
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please why did you delete the event? 👀

world.write_model(@game);
Expand All @@ -843,6 +870,57 @@ pub mod actions {
}
}

/// betting round completion check @kaylahray
fn is_betting_round_complete(
self: @ContractState, game: @Game, world: @dojo::world::WorldStorage,
) -> bool {
let mut all_equal_bets = true;
let mut active_players = 0_u32;

// Single pass through players to check betting status @kaylahray
for player_addr in game.players.span() {
let p: Player = world.read_model(*player_addr);
if p.in_round {
active_players += 1;
if p.current_bet != *game.current_bet {
all_equal_bets = false;
break;
}
}
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the last review, I said you shouldn't loop per player, it's just brute force... eats gas. what if there are thirty players at the table?

By the way, didn't this logic over here work?
Screenshot 2025-08-06 at 11 15 56 AM
no?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image


// @kaylahray Betting round complete if all active players have equal bets and the game
// bet is not zero
all_equal_bets && active_players > 1 && *game.current_bet > 0
}

/// @kaylahray batch reset of betting round
fn reset_betting_round(
ref self: ContractState, game_id: u64, ref game: Game, ref world: WorldStorage,
) {
// Reset game state
game.highest_staker = Option::None;
game.current_bet = 0;

// Determine next phase
if game.community_cards.len() == 5 {
game.showdown = true;
} else {
game.community_dealing = true;
}

// Batch reset all players' current_bet using write_member for better gas efficiency
for player_addr in game.players.span() {
world
.write_member(
Model::<Player>::ptr_from_keys(*player_addr),
selector!("current_bet"),
0_u256,
);
}
}


fn find_player_index(
self: @ContractState, players: @Array<ContractAddress>, player_address: ContractAddress,
) -> Option<usize> {
Expand Down
1 change: 1 addition & 0 deletions poker-texas-hold-em/contract/src/tests/setup.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mod setup {
use poker::models::game::m_Game;
use poker::models::hand::m_Hand;
use poker::models::player::m_Player;
use core::zeroable::Zeroable;


#[starknet::interface]
Expand Down
109 changes: 108 additions & 1 deletion poker-texas-hold-em/contract/src/tests/test_actions.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,113 @@ mod tests {
);
}

// [Betting Logic Tests] - Testing highest staker and bet reset functionality @kaylahray
#[test]
fn test_highest_staker_and_bet_reset() {
// [Setup]
let contracts = array![CoreContract::Actions];
let (mut world, systems) = deploy_contracts(contracts);
mock_poker_game(ref world);

// Player 1 (PLAYER_1) raises @kaylahray
let mut game: Game = world.read_model(1);
let raise_amount = game.params.small_blind * 4; // Example raise = 40
set_contract_address(PLAYER_1());
systems.actions.raise(raise_amount.into());

// Check that highest_staker is set correctly
game = world.read_model(1);
assert!(game.highest_staker.is_some(), "Highest staker not set on raise");
assert_eq!(game.highest_staker.unwrap(), PLAYER_1(), "Incorrect highest staker");
assert_eq!(game.current_bet, raise_amount.into(), "Game bet not updated on raise");

// Player 2 calls - should match game.current_bet
set_contract_address(PLAYER_2());
systems.actions.call();

// Check player 2 state after call
let player_2_after_call: Player = world.read_model(PLAYER_2());
assert_eq!(
player_2_after_call.current_bet, raise_amount.into(), "Player 2 didn't call correctly",
);

// Check if betting round completion would be detected at this point
game = world.read_model(1);
let player_1_mid: Player = world.read_model(PLAYER_1());
// At this point: player1=40, player2=40, player3=0, game=40
// So betting round should NOT be complete yet

// Player 3 calls - this should complete the betting round and reset state
set_contract_address(PLAYER_3());
systems.actions.call();

// Check all player states immediately after the call
game = world.read_model(1);
let player_1: Player = world.read_model(PLAYER_1());
let player_2: Player = world.read_model(PLAYER_2());
let player_3: Player = world.read_model(PLAYER_3());

// At this point, all players should have current_bet = 40, game.current_bet = 40
// The betting round should be complete and reset should have happened
assert!(game.highest_staker.is_none(), "Highest staker not reset");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then the reset is complete. Check the community_dealing. As per last review on the tests, and the issue description, community dealing must be tested to. After first first round of betting, deal_community_card() must go through three times.

Add test: The fourth one must panic
Add test: After adding three community cards, the betting should resume
Add test: Try any of the betting functions when the betting round has ended. This should panic.

and this just means that the betting cycle you wrote above can be extracted into a separate function called feign_betting_round() or something, and can be reused in any of the three tests as above.

There are other edge cases tests, but I'll leave it for another issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image image

Trying two betting functions 👇
image

assert_eq!(game.current_bet, 0, "Game current bet not reset");
assert_eq!(player_1.current_bet, 0, "Player 1 bet not reset");
assert_eq!(player_2.current_bet, 0, "Player 2 bet not reset");
assert_eq!(player_3.current_bet, 0, "Player 3 bet not reset");
}

// @kaylahray Testing bet spacing logic
#[test]
#[should_panic(expected: ('Invalid raise spacing', 'ENTRYPOINT_FAILED'))]
fn test_bet_spacing_fail() {
// [Setup]
let contracts = array![CoreContract::Actions];
let (mut world, systems) = deploy_contracts(contracts);
mock_poker_game(ref world);

let game: Game = world.read_model(1);
// Not a multiple of bet_spacing (which is small_blind)
let raise_amount = game.params.small_blind * 2 + 1;
set_contract_address(PLAYER_1());
systems.actions.raise(raise_amount.into());
}
// @kaylahray Testing bet spacing success
#[test]
fn test_bet_spacing_success() {
// [Setup]
let contracts = array![CoreContract::Actions];
let (mut world, systems) = deploy_contracts(contracts);
mock_poker_game(ref world);

let game: Game = world.read_model(1);
let raise_amount = game.params.small_blind * 4; // 40 is multiple of bet_spacing (20)
set_contract_address(PLAYER_1());
systems.actions.raise(raise_amount.into());

let updated_game: Game = world.read_model(1);
assert_eq!(updated_game.current_bet, raise_amount.into(), "Bet spacing success failed");
}
// @kaylahray Testing all-in sets highest staker
#[test]
fn test_all_in_sets_highest_staker() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per the issue description, The aim was for all_in to work regardless of the bet_spacing. So write a new test that mocks the player's balance to not be a multiple of the bet. spacing and call the all_in. It should pass

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

// [Setup]
let contracts = array![CoreContract::Actions];
let (mut world, systems) = deploy_contracts(contracts);
mock_poker_game(ref world);

// Player 1 goes all-in
set_contract_address(PLAYER_1());
systems.actions.all_in();

let game: Game = world.read_model(1);
let player_1: Player = world.read_model(PLAYER_1());

assert!(game.highest_staker.is_some(), "Highest staker not set on all-in");
assert_eq!(game.highest_staker.unwrap(), player_1.id, "Incorrect highest staker on all-in");
assert_eq!(game.current_bet, player_1.current_bet, "Game bet not updated on all-in");
assert_eq!(player_1.chips, 0, "Player should have 0 chips after all-in");
}

// [Mocks]
fn mock_poker_game(ref world: WorldStorage) {
let game = Game {
Expand All @@ -350,7 +457,7 @@ mod tests {
has_ended: false,
current_round: 1,
round_in_progress: true,
current_player_count: 2,
current_player_count: 3,
players: array![PLAYER_1(), PLAYER_2(), PLAYER_3()],
deck: array![],
next_player: Option::Some(PLAYER_1()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ mod tests {
kicker_split: kicker_split,
min_amount_of_chips: 2000,
blind_spacing: 10,
bet_spacing: 20,
showdown_type: ShowdownType::Gathered,
}
}
Expand Down
3 changes: 3 additions & 0 deletions poker-texas-hold-em/contract/src/traits/game.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const MIN_BIG_BLIND: u64 = 2;
const MIN_NO_OF_DECKS: u8 = 1;
const MIN_AMOUNT_OF_CHIPS: u256 = 10;
const MIN_BLIND_SPACING: u16 = 1;
const MIN_BET_SPACING: u256 = 1;

#[generate_trait]
pub impl GameImpl of GameTrait {
Expand All @@ -36,6 +37,7 @@ pub impl GameImpl of GameTrait {
GameErrors::INVALID_GAME_PARAMS,
);
assert(params.blind_spacing >= MIN_BLIND_SPACING, GameErrors::INVALID_GAME_PARAMS);
assert(params.bet_spacing >= MIN_BET_SPACING, GameErrors::INVALID_GAME_PARAMS);
params
},
Option::None => get_default_game_params(),
Expand Down Expand Up @@ -72,6 +74,7 @@ fn get_default_game_params() -> GameParams {
kicker_split: true,
min_amount_of_chips: 100,
blind_spacing: 10,
bet_spacing: 20,
showdown_type: Default::default(),
}
}
Loading