From 49df948de25d28601469e9ca993f33530989ebd0 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 29 Oct 2026 22:55:49 +0100 Subject: v3.0.0: cursor-driven roulette felt with splits and corners; drop schema migrations; require 80x24 --- tests/roulette.rs | 334 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 252 insertions(+), 82 deletions(-) (limited to 'tests/roulette.rs') diff --git a/tests/roulette.rs b/tests/roulette.rs index 932bace..8f9155c 100644 --- a/tests/roulette.rs +++ b/tests/roulette.rs @@ -1,4 +1,7 @@ -use zagan_bet::roulette::{color, is_black, is_even, is_odd, is_red, rtp, Bets, NumberBet, WHEEL}; +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; @@ -31,102 +34,184 @@ fn european_rtp_is_36_over_37() { } #[test] -fn bets_total_and_winning_number_payouts() { - let bets = Bets { - red: 10, - black: 10, - even: 10, - odd: 10, - numbers: vec![NumberBet { - number: 17, - amount: 5, - }], +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"); - assert_eq!(bets.total(), 45); - // 17 is black and odd: 10*2 + 10*2 + 5*36. - assert_eq!(bets.winnings(17), 220); - // 18 is red and even. - assert_eq!(bets.winnings(18), 40); + 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 zero_loses_all_outside_bets_but_pays_a_straight_zero() { - let outside = Bets { - red: 10, - black: 10, - even: 10, - odd: 10, - numbers: vec![], - }; - assert_eq!(outside.winnings(0), 0); - - let straight = Bets { - red: 10, - black: 10, - even: 10, - odd: 10, - numbers: vec![NumberBet { - number: 0, - amount: 5, - }], - }; - assert_eq!(straight.total(), 45); - assert_eq!(straight.winnings(0), 180); +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_validate_empty_balance_and_invalid_numbers() { +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 too_big = Bets { - red: 2000, - ..Bets::default() - }; + 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 { - numbers: vec![NumberBet { - number: 37, + chips: vec![zagan_bet::roulette::Chip { + target: BetTarget::Straight { number: 37 }, amount: 10, }], - ..Bets::default() }; assert!(bad_number.validate(1000).is_err()); -} -#[test] -fn the_server_settles_a_split_bet() { - let bets = Bets { - red: 10, - black: 10, - even: 10, - odd: 10, - numbers: vec![NumberBet { - number: 17, - amount: 5, + let bad_split = Bets { + chips: vec![zagan_bet::roulette::Chip { + target: BetTarget::Split { a: 4, b: 4 }, + amount: 10, }], }; - let payload = serde_json::to_string(&bets).unwrap(); - let game = RouletteGame::with_result(17); - let state = game.prepare_state("alice", bets.total()); + 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(); - assert_eq!(result.winnings, 220); - assert_eq!(result.result_data.get("number").unwrap(), "17"); + // 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 payload = serde_json::to_string(&Bets { - red: 10, - ..Bets::default() - }) - .unwrap(); + 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); @@ -134,31 +219,116 @@ fn the_server_rejects_a_total_that_does_not_match_the_bets() { } #[test] -fn the_server_rejects_an_invalid_number() { - let payload = serde_json::to_string(&Bets { - numbers: vec![NumberBet { - number: 37, - amount: 10, - }], - ..Bets::default() - }) - .unwrap(); +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()); + 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 lattice_points_become_straights_splits_and_corners() { + assert_eq!( + target_at(0, 3), + BetTarget::Straight { number: 1 }, + "bottom-left corner is a straight" + ); + assert_eq!(target_at(0, 0), BetTarget::Straight { number: 3 }); + assert_eq!(target_at(12, 0), BetTarget::Straight { number: 36 }); + assert_eq!(target_at(12, 3), BetTarget::Straight { number: 34 }); + + assert_eq!(target_at(0, 1), BetTarget::Split { a: 2, b: 3 }); + assert_eq!(target_at(12, 1), BetTarget::Split { a: 35, b: 36 }); + assert_eq!(target_at(1, 0), BetTarget::Split { a: 3, b: 6 }); + + assert_eq!( + target_at(1, 1), + BetTarget::Corner { + numbers: [2, 3, 5, 6] + } + ); + assert_eq!( + target_at(6, 2), + BetTarget::Corner { + numbers: [16, 17, 19, 20] + } + ); +} + +#[test] +fn the_cursor_walks_the_felt_and_the_outside_rows() { + let bottom = Cursor::Lattice { x: 0, y: 3 }; + assert_eq!(bottom.moved(Direction::Down), Cursor::Dozen(0)); + assert_eq!( + Cursor::Lattice { x: 12, y: 3 }.moved(Direction::Down), + Cursor::Dozen(2) + ); + assert_eq!( + Cursor::Lattice { x: 0, y: 0 }.moved(Direction::Up), + Cursor::Zero + ); + assert_eq!(Cursor::Zero.moved(Direction::Down), Cursor::Lattice { x: 0, y: 0 }); + + assert_eq!(Cursor::Dozen(0).moved(Direction::Up), Cursor::Lattice { x: 1, y: 3 }); + assert_eq!(Cursor::Dozen(0).moved(Direction::Down), Cursor::Column(0)); + 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::Dozen(0).moved(Direction::Left), Cursor::Dozen(0)); + assert_eq!(Cursor::Outside(5).moved(Direction::Right), Cursor::Outside(5)); + assert_eq!( + Cursor::Lattice { x: 0, y: 2 }.moved(Direction::Left), + Cursor::Lattice { x: 0, y: 2 } + ); +} + +#[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("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#"{"red":10,"black":0,"even":10,"odd":0,"numbers":[{"number":17,"amount":10}]}"#; - let response = format!("ROULETTE_RESULT session-1 360 30 1330 17 black {bets}"); + 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, 360); + assert_eq!(result.winnings, 60); assert_eq!(result.bet_amount, 30); - assert_eq!(result.balance, 1330); + assert_eq!(result.balance, 1030); assert_eq!(result.number, 17); assert_eq!(result.color, "black"); assert_eq!(result.bets, bets); @@ -168,7 +338,7 @@ fn parses_a_roulette_result() { fn rejects_bad_roulette_results() { use zagan_bet::client::games::response::parse_roulette_response; - let valid_bets = r#"{"red":10}"#; + let valid_bets = r#"{"chips":[]}"#; assert!( parse_roulette_response(&format!("ROULETTE_RESULT s 1 1 1 37 green {valid_bets}")).is_err() ); -- cgit v1.2.3