use zagan_bet::client::games::roulette::{Cursor, Direction}; use zagan_bet::roulette::{ color, is_black, is_even, is_odd, is_red, rtp, BetTarget, Bets, Outside, WHEEL, }; use zagan_bet::server::games::roulette::RouletteGame; use zagan_bet::server::games::traits::Game; #[test] fn the_european_wheel_has_all_37_unique_pockets() { let mut numbers = WHEEL.to_vec(); numbers.sort_unstable(); assert_eq!(numbers, (0..=36).collect::>()); assert_eq!(color(0), "green"); assert_eq!(WHEEL.len(), 37); } #[test] fn colors_and_even_odd_are_correct() { assert!(is_red(1)); assert!(is_black(2)); assert!(!is_red(0)); assert!(!is_black(0)); assert!(is_even(2)); assert!(is_odd(3)); assert!(!is_even(0)); assert!(!is_odd(0)); assert_eq!(color(17), "black"); assert_eq!(color(18), "red"); } #[test] fn european_rtp_is_36_over_37() { assert!((rtp() - 36.0 / 37.0).abs() < 1e-12); } #[test] fn inside_bets_pay_their_true_odds() { let straight = BetTarget::Straight { number: 17 }; assert_eq!(straight.return_multiplier(17), 36); assert_eq!(straight.return_multiplier(18), 0); let split = BetTarget::Split { a: 1, b: 2 }; assert_eq!(split.return_multiplier(1), 18); assert_eq!(split.return_multiplier(2), 18); assert_eq!(split.return_multiplier(3), 0); assert_eq!(split.numbers(), vec![1, 2]); let corner = BetTarget::Corner { numbers: [1, 2, 4, 5], }; for number in [1, 2, 4, 5] { assert_eq!(corner.return_multiplier(number), 9); } assert_eq!(corner.return_multiplier(3), 0); assert_eq!(corner.numbers(), vec![1, 2, 4, 5]); } #[test] fn dozens_and_columns_pay_three_for_one_and_zero_loses() { let first_dozen = BetTarget::Dozen { index: 0 }; assert_eq!(first_dozen.return_multiplier(1), 3); assert_eq!(first_dozen.return_multiplier(12), 3); assert_eq!(first_dozen.return_multiplier(13), 0); assert_eq!(first_dozen.return_multiplier(0), 0); let second_dozen = BetTarget::Dozen { index: 1 }; assert_eq!(second_dozen.return_multiplier(13), 3); assert_eq!(second_dozen.return_multiplier(24), 3); let first_column = BetTarget::Column { index: 0 }; assert_eq!(first_column.return_multiplier(1), 3); assert_eq!(first_column.return_multiplier(34), 3); assert_eq!(first_column.return_multiplier(2), 0); let third_column = BetTarget::Column { index: 2 }; assert_eq!(third_column.return_multiplier(36), 3); assert_eq!(third_column.return_multiplier(0), 0, "0 is not in a column"); for index in 0..3 { let dozen = BetTarget::Dozen { index }; let winning = index as u8 * 12 + 1; assert_eq!(dozen.return_multiplier(winning), 3); } } #[test] fn outside_bets_pay_even_money_and_zero_loses_everything() { let cases = [ (Outside::Low, 18u8, 19u8), (Outside::High, 19, 18), (Outside::Even, 2, 3), (Outside::Odd, 3, 2), (Outside::Red, 1, 2), (Outside::Black, 2, 1), ]; for (kind, winning, losing) in cases { let bet = BetTarget::Outside { kind }; assert_eq!(bet.return_multiplier(winning), 2, "{kind:?} should win"); assert_eq!(bet.return_multiplier(losing), 0, "{kind:?} should lose"); assert_eq!(bet.return_multiplier(0), 0, "0 loses every outside bet"); } } #[test] fn chips_on_the_same_target_merge() { let mut bets = Bets::default(); assert!(bets.is_empty()); bets.add(BetTarget::Straight { number: 17 }, 10); bets.add(BetTarget::Straight { number: 17 }, 5); bets.add(BetTarget::Outside { kind: Outside::Red }, 10); assert_eq!(bets.chips.len(), 2); assert_eq!( bets.amount_on(&BetTarget::Straight { number: 17 }), 15 ); assert_eq!(bets.total(), 25); assert_eq!(bets.winnings(17), 15 * 36); } #[test] fn bets_settle_every_chip_independently() { let mut bets = Bets::default(); bets.add(BetTarget::Split { a: 1, b: 2 }, 10); bets.add(BetTarget::Outside { kind: Outside::Red }, 10); bets.add(BetTarget::Straight { number: 1 }, 5); // 2 is black, so the red chip loses; the split pays 18x. assert_eq!(bets.winnings(2), 180); // 3 is red, but the straight is on 1, so only the red chip pays. assert_eq!(bets.winnings(3), 20); // 1 is red, matches the straight, and is half of the split. assert_eq!(bets.winnings(1), 5 * 36 + 10 * 2 + 10 * 18); } #[test] fn a_straight_zero_is_paid_while_outside_bets_lose() { let mut bets = Bets::default(); bets.add(BetTarget::Straight { number: 0 }, 5); bets.add(BetTarget::Outside { kind: Outside::Red }, 10); bets.add(BetTarget::Dozen { index: 0 }, 10); assert_eq!(bets.total(), 25); assert_eq!(bets.winnings(0), 180); } #[test] fn bets_validate_balance_and_targets() { assert!(Bets::default().validate(1000).is_err()); let mut too_big = Bets::default(); too_big.add(BetTarget::Outside { kind: Outside::Black }, 2000); assert!(too_big.validate(1000).is_err()); let bad_number = Bets { chips: vec![zagan_bet::roulette::Chip { target: BetTarget::Straight { number: 37 }, amount: 10, }], }; assert!(bad_number.validate(1000).is_err()); let bad_split = Bets { chips: vec![zagan_bet::roulette::Chip { target: BetTarget::Split { a: 4, b: 4 }, amount: 10, }], }; assert!(bad_split.validate(1000).is_err()); let bad_corner = Bets { chips: vec![zagan_bet::roulette::Chip { target: BetTarget::Corner { numbers: [1, 2, 3, 3], }, amount: 10, }], }; assert!(bad_corner.validate(1000).is_err()); let bad_dozen = Bets { chips: vec![zagan_bet::roulette::Chip { target: BetTarget::Dozen { index: 3 }, amount: 10, }], }; assert!(bad_dozen.validate(1000).is_err()); } #[test] fn the_server_settles_split_and_outside_chips() { let mut bets = Bets::default(); bets.add(BetTarget::Split { a: 1, b: 2 }, 10); bets.add(BetTarget::Outside { kind: Outside::Even }, 10); let payload = bets.to_json(); let game = RouletteGame::with_result(2); let state = game.prepare_state("alice", bets.total()); let state = game.process_action(&state, "spin", &[&payload]).unwrap(); let result = game.calculate_result(&state).unwrap(); // 2 is even: 10*18 (split) + 10*2 (even money). assert_eq!(result.winnings, 200); assert_eq!(result.result_data.get("number").unwrap(), "2"); assert_eq!(result.result_data.get("color").unwrap(), "black"); assert_eq!(result.result_data.get("bets").unwrap(), &payload); } #[test] fn the_server_rejects_a_total_that_does_not_match_the_bets() { let mut bets = Bets::default(); bets.add(BetTarget::Outside { kind: Outside::Red }, 10); let payload = bets.to_json(); let game = RouletteGame::with_result(17); let state = game.prepare_state("alice", 99); assert!(game.process_action(&state, "spin", &[&payload]).is_err()); } #[test] fn the_server_rejects_an_invalid_target() { let payload = r#"{"chips":[{"target":{"bet":"straight","number":37},"amount":10}]}"#; let game = RouletteGame::with_result(17); let state = game.prepare_state("alice", 10); assert!(game.process_action(&state, "spin", &[payload]).is_err()); } // --- betting cursor ------------------------------------------------------- fn target_at(x: usize, y: usize) -> BetTarget { Cursor::Lattice { x, y }.target().unwrap() } #[test] fn cell_centres_are_straights() { assert_eq!(target_at(1, 5), BetTarget::Straight { number: 1 }); assert_eq!(target_at(1, 1), BetTarget::Straight { number: 3 }); assert_eq!(target_at(23, 1), BetTarget::Straight { number: 36 }); assert_eq!(target_at(23, 5), BetTarget::Straight { number: 34 }); assert_eq!(target_at(5, 3), BetTarget::Straight { number: 8 }); // Every one of the 36 numbers can be bet straight. let mut reachable = Vec::new(); for column in 0..12usize { for row in 0..3usize { let cursor = Cursor::Lattice { x: 2 * column + 1, y: 2 * row + 1, }; if let Some(BetTarget::Straight { number }) = cursor.target() { reachable.push(number); } } } reachable.sort_unstable(); assert_eq!(reachable, (1..=36).collect::>()); } #[test] fn lines_between_cells_are_splits_and_crossings_are_corners() { // Vertical lines sit between two columns. assert_eq!(target_at(2, 5), BetTarget::Split { a: 1, b: 4 }); assert_eq!(target_at(2, 3), BetTarget::Split { a: 2, b: 5 }); assert_eq!(target_at(22, 1), BetTarget::Split { a: 33, b: 36 }); // Horizontal lines sit between two rows. assert_eq!(target_at(1, 4), BetTarget::Split { a: 1, b: 2 }); assert_eq!(target_at(3, 2), BetTarget::Split { a: 5, b: 6 }); assert_eq!(target_at(23, 4), BetTarget::Split { a: 34, b: 35 }); // Crossings cover four cells. assert_eq!( target_at(2, 4), BetTarget::Corner { numbers: [1, 2, 4, 5] } ); assert_eq!( target_at(12, 4), BetTarget::Corner { numbers: [16, 17, 19, 20] } ); } #[test] fn the_cursor_walks_the_felt_and_the_outside_rows() { let bottom = Cursor::Lattice { x: 1, y: 5 }; // One step right is the split between 1 and 4, two steps is number 4. assert_eq!( bottom.moved(Direction::Right), Cursor::Lattice { x: 2, y: 5 } ); assert_eq!( bottom.moved(Direction::Right).moved(Direction::Right), Cursor::Lattice { x: 3, y: 5 } ); // One step up is the split between 1 and 2. assert_eq!(bottom.moved(Direction::Up), Cursor::Lattice { x: 1, y: 4 }); assert_eq!(bottom.moved(Direction::Down), Cursor::Dozen(0)); assert_eq!( Cursor::Lattice { x: 23, y: 5 }.moved(Direction::Down), Cursor::Dozen(2) ); assert_eq!( Cursor::Lattice { x: 1, y: 1 }.moved(Direction::Up), Cursor::Zero ); assert_eq!( Cursor::Zero.moved(Direction::Down), Cursor::Lattice { x: 1, y: 1 } ); assert_eq!( Cursor::Dozen(0).moved(Direction::Up), Cursor::Lattice { x: 1, y: 5 } ); assert_eq!(Cursor::Dozen(0).moved(Direction::Down), Cursor::Column(0)); assert_eq!(Cursor::Dozen(2).moved(Direction::Up), Cursor::Lattice { x: 17, y: 5 }); assert_eq!(Cursor::Column(2).moved(Direction::Up), Cursor::Dozen(2)); assert_eq!(Cursor::Column(1).moved(Direction::Down), Cursor::Outside(2)); assert_eq!(Cursor::Outside(3).moved(Direction::Up), Cursor::Column(1)); // Edges stay put instead of wrapping. assert_eq!( Cursor::Lattice { x: 1, y: 3 }.moved(Direction::Left), Cursor::Lattice { x: 1, y: 3 } ); assert_eq!( Cursor::Lattice { x: 23, y: 3 }.moved(Direction::Right), Cursor::Lattice { x: 23, y: 3 } ); assert_eq!(Cursor::Dozen(0).moved(Direction::Left), Cursor::Dozen(0)); assert_eq!(Cursor::Outside(5).moved(Direction::Right), Cursor::Outside(5)); } #[test] fn every_cursor_round_trips_through_its_encoded_form() { let cursors = [ Cursor::Zero, Cursor::Lattice { x: 7, y: 2 }, Cursor::Dozen(1), Cursor::Column(2), Cursor::Outside(4), ]; for cursor in cursors { assert_eq!(Cursor::parse(&cursor.encoded()), cursor); } assert_eq!(Cursor::parse("nonsense"), Cursor::default()); assert_eq!(Cursor::parse("lattice:99:0"), Cursor::default()); assert_eq!(Cursor::parse("lattice:0:1"), Cursor::default()); assert_eq!(Cursor::parse("dozen:7"), Cursor::default()); } #[test] fn the_outside_cursor_targets_match_the_labels() { let labels: Vec = (0..6) .map(|index| { let target = Cursor::Outside(index).target().unwrap(); target.label() }) .collect(); assert_eq!(labels, vec!["1-18", "EVEN", "RED", "BLACK", "ODD", "19-36"]); } #[test] fn parses_a_roulette_result() { let bets = r#"{"chips":[{"target":{"bet":"outside","kind":"red"},"amount":30}]}"#; let response = format!("ROULETTE_RESULT session-1 60 30 1030 17 black {bets}"); let result = zagan_bet::client::games::response::parse_roulette_response(&response).unwrap(); assert_eq!(result.session_id, "session-1"); assert_eq!(result.winnings, 60); assert_eq!(result.bet_amount, 30); assert_eq!(result.balance, 1030); assert_eq!(result.number, 17); assert_eq!(result.color, "black"); assert_eq!(result.bets, bets); } #[test] fn rejects_bad_roulette_results() { use zagan_bet::client::games::response::parse_roulette_response; let valid_bets = r#"{"chips":[]}"#; assert!( parse_roulette_response(&format!("ROULETTE_RESULT s 1 1 1 37 green {valid_bets}")).is_err() ); assert!( parse_roulette_response(&format!("ROULETTE_RESULT s 1 1 1 17 purple {valid_bets}")) .is_err() ); assert!(parse_roulette_response("ROULETTE_RESULT s 1 1 1 17 black nope").is_err()); assert!(parse_roulette_response("").is_err()); }