summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 20:43:09 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 20:43:09 +0100
commit6e2787d792de64c003eff03b62a2df798ef851c0 (patch)
tree0e9bf97b2f71df5fb9eb00d5195d32b8326886ff /tests
parentv2.2.1: forfeit blackjack bets when leaving or disconnecting (diff)
downloadzagan-bet-6e2787d792de64c003eff03b62a2df798ef851c0.tar.gz
zagan-bet-6e2787d792de64c003eff03b62a2df798ef851c0.zip
v2.3.0: add crash with local multiplier and pushed crash event
Diffstat (limited to '')
-rw-r--r--tests/client_core.rs210
-rw-r--r--tests/crash.rs100
-rw-r--r--tests/server_core.rs245
3 files changed, 554 insertions, 1 deletions
diff --git a/tests/client_core.rs b/tests/client_core.rs
index ec444bd..e8b5646 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -6,8 +6,12 @@
6 6
7use zagan_bet::blackjack::BlackjackState; 7use zagan_bet::blackjack::BlackjackState;
8use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; 8use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key};
9use zagan_bet::client::games::response::{parse_blackjack_response, parse_spin_response}; 9use zagan_bet::client::games::response::{
10 parse_blackjack_response, parse_crash_response, parse_spin_response,
11};
10use zagan_bet::client::ui::command_names; 12use zagan_bet::client::ui::command_names;
13use zagan_bet::crash::CrashState;
14use std::time::Duration;
11 15
12const WIN3: &str = "CCCCLLCLODGSDGS"; 16const WIN3: &str = "CCCCLLCLODGSDGS";
13const LOSS: &str = "CLOOGDSLCDCOGLS"; 17const LOSS: &str = "CLOOGDSLCDCOGLS";
@@ -145,6 +149,25 @@ impl Harness {
145 self.line("play blackjack"); 149 self.line("play blackjack");
146 self.reply("INFO alice 1 1000"); 150 self.reply("INFO alice 1 1000");
147 } 151 }
152
153 fn enter_crash(&mut self) {
154 self.line("play crash");
155 self.reply("INFO alice 1 1000");
156 }
157
158 fn tick(&mut self, elapsed: Duration) {
159 let effects = self.core.tick_at(elapsed);
160 self.apply(effects);
161 }
162
163 /// Deliver a server-initiated line the way the terminal loop does.
164 fn push(&mut self, line: &str) {
165 let effects = self
166 .core
167 .handle_async_line(line)
168 .expect("a pushed crash line");
169 self.apply(effects);
170 }
148} 171}
149 172
150#[test] 173#[test]
@@ -851,3 +874,188 @@ fn parses_a_blackjack_state() {
851 assert_eq!(parse_blackjack_response(&response).unwrap(), state); 874 assert_eq!(parse_blackjack_response(&response).unwrap(), state);
852 assert!(parse_blackjack_response("ERROR Insufficient balance").is_err()); 875 assert!(parse_blackjack_response("ERROR Insufficient balance").is_err());
853} 876}
877
878fn crash_state(phase: &str, multiplier: f64, crash_point: Option<f64>, payout: u64, balance: i64) -> CrashState {
879 CrashState {
880 session_id: "cr1".to_string(),
881 phase: phase.to_string(),
882 bet: 10,
883 balance,
884 multiplier,
885 potential: (10.0 * multiplier) as u64,
886 crash_point,
887 payout,
888 result: phase.to_string(),
889 }
890}
891
892fn crash_line(prefix: &str, state: &CrashState) -> String {
893 format!("{prefix} {}", serde_json::to_string(state).unwrap())
894}
895
896#[test]
897fn crash_starts_a_round_and_draws_the_multiplier_locally() {
898 let mut h = Harness::new();
899 h.enter_crash();
900
901 assert_eq!(h.board().len(), 6);
902 assert!(h.board()[4].contains("Press [SPACE] to start."));
903 assert!(h.board()[5].contains("[SPACE] Play"));
904
905 h.key(Key::Char(' '));
906 assert!(h.sent.iter().any(|line| line == "PLAY_START crash 10"));
907
908 let running = crash_state("running", 1.0, None, 0, 990);
909 h.reply(&crash_line("CRASH_START", &running));
910
911 assert_eq!(h.board().len(), 6);
912 assert!(h.board()[2].contains("Multiplier: 1.00x"));
913 assert!(h.board()[3].contains("Potential: 10 PLN"));
914 assert!(h.board()[4].contains("Rising"));
915 assert!(h.board()[5].contains("[C] Cash out"));
916
917 // The multiplier is drawn from the local clock, with no server round trip.
918 h.tick(Duration::from_secs(5));
919 assert!(h.board()[2].contains("Multiplier: 2.00x"));
920 assert!(h.board()[3].contains("Potential: 20 PLN"));
921
922 h.key(Key::Char('c'));
923 assert_eq!(
924 h.sent.last().map(String::as_str),
925 Some("CRASH_CASHOUT cr1")
926 );
927
928 let cashed = crash_state("cashed", 2.0, Some(3.0), 20, 1010);
929 h.reply(&crash_line("CRASH_STATE", &cashed));
930
931 assert!(h.board()[4].contains("Cashed out at 2.00x Won 20 PLN"));
932 assert!(h.board()[5].contains("[SPACE] Play"));
933 assert_eq!(h.board()[1], "Balance: 1010 PLN Bet: 10 PLN");
934}
935
936#[test]
937fn a_pushed_bust_ends_the_round_without_a_request() {
938 let mut h = Harness::new();
939 h.enter_crash();
940 h.key(Key::Char(' '));
941
942 let running = crash_state("running", 1.0, None, 0, 990);
943 h.reply(&crash_line("CRASH_START", &running));
944 h.tick(Duration::from_secs(3));
945
946 assert!(h.sent.iter().all(|line| line != "CRASH_CASHOUT cr1"));
947
948 let busted = crash_state("crashed", 1.5, Some(1.5), 0, 990);
949 h.push(&crash_line("CRASH_BUSTED", &busted));
950
951 assert!(h.board()[4].contains("CRASHED at 1.50x Bet lost"));
952 assert!(h.board()[5].contains("[SPACE] Play"));
953
954 // The local clock stops once the round is over.
955 h.tick(Duration::from_secs(30));
956 assert!(h.board()[2].contains("Multiplier: 1.50x"));
957 assert!(!h.core.expects_push());
958}
959
960#[test]
961fn crash_q_forfeits_an_active_round() {
962 let mut h = Harness::new();
963 h.enter_crash();
964 h.key(Key::Char(' '));
965
966 let running = crash_state("running", 1.0, None, 0, 990);
967 h.reply(&crash_line("CRASH_START", &running));
968
969 h.key(Key::Char('q'));
970 assert_eq!(
971 h.sent.last().map(String::as_str),
972 Some("CRASH_FORFEIT cr1")
973 );
974
975 let forfeited = crash_state("forfeit", 1.2, Some(2.0), 0, 990);
976 h.reply(&crash_line("CRASH_STATE", &forfeited));
977
978 assert!(h.board().is_empty());
979 assert!(h
980 .screen
981 .lines
982 .iter()
983 .any(|line| line.contains("Round forfeited (10 PLN lost)")));
984}
985
986#[test]
987fn crash_ready_board_changes_the_next_bet() {
988 let mut h = Harness::new();
989 h.enter_crash();
990
991 h.key(Key::Char('='));
992 assert!(h.board()[1].contains("Bet: 20 PLN"));
993 assert!(h.board()[3].contains("Potential: 20 PLN"));
994
995 h.key(Key::Char(' '));
996 assert!(h.sent.iter().any(|line| line == "PLAY_START crash 20"));
997}
998
999#[test]
1000fn parses_crash_messages_and_errors() {
1001 let running = crash_state("running", 1.0, None, 0, 990);
1002 let cashed = crash_state("cashed", 2.0, Some(3.0), 20, 1010);
1003
1004 assert_eq!(
1005 parse_crash_response(&crash_line("CRASH_START", &running)).unwrap(),
1006 running
1007 );
1008 assert_eq!(
1009 parse_crash_response(&crash_line("CRASH_BUSTED", &cashed)).unwrap(),
1010 cashed
1011 );
1012 assert_eq!(
1013 parse_crash_response("ERROR Round already ended").unwrap_err(),
1014 "Round already ended"
1015 );
1016 assert!(parse_crash_response("GAME_RESULT nope").is_err());
1017}
1018
1019#[test]
1020fn a_late_cashout_does_not_overwrite_a_pushed_bust() {
1021 let mut h = Harness::new();
1022 h.enter_crash();
1023 h.key(Key::Char(' '));
1024
1025 let running = crash_state("running", 1.0, None, 0, 990);
1026 h.reply(&crash_line("CRASH_START", &running));
1027
1028 h.key(Key::Char('c'));
1029 assert_eq!(h.sent.last().map(String::as_str), Some("CRASH_CASHOUT cr1"));
1030
1031 // The round crashes while the cashout is in flight, so the push lands
1032 // before the server's "session not found" reply.
1033 let busted = crash_state("crashed", 1.2, Some(1.2), 0, 990);
1034 h.push(&crash_line("CRASH_BUSTED", &busted));
1035 h.reply("ERROR Session not found");
1036
1037 assert!(h.board()[4].contains("CRASHED at 1.20x"));
1038 assert!(!h.board().iter().any(|line| line.contains("ERROR")));
1039 assert!(h.board()[5].contains("[SPACE] Play"));
1040}
1041
1042#[test]
1043fn a_forfeit_that_arrives_after_the_bust_still_leaves_cleanly() {
1044 let mut h = Harness::new();
1045 h.enter_crash();
1046 h.key(Key::Char(' '));
1047
1048 let running = crash_state("running", 1.0, None, 0, 990);
1049 h.reply(&crash_line("CRASH_START", &running));
1050
1051 h.key(Key::Char('q'));
1052 assert_eq!(h.sent.last().map(String::as_str), Some("CRASH_FORFEIT cr1"));
1053
1054 let busted = crash_state("crashed", 1.5, Some(1.5), 0, 990);
1055 h.push(&crash_line("CRASH_BUSTED", &busted));
1056 h.reply("ERROR Session not found");
1057
1058 assert!(h.board().is_empty());
1059 assert!(h.screen.lines.iter().any(|line| line == "Game exited."));
1060 assert!(!h.screen.lines.iter().any(|line| line.contains("PLN lost")));
1061}
diff --git a/tests/crash.rs b/tests/crash.rs
new file mode 100644
index 0000000..f59c52f
--- /dev/null
+++ b/tests/crash.rs
@@ -0,0 +1,100 @@
1//! Crash math: the curve, the payout floor, and the house edge.
2
3use std::time::Duration;
4use zagan_bet::crash::{
5 crash_point_from_uniform, multiplier_at, multiplier_at_secs, payout, time_for_multiplier,
6 HOUSE_EDGE, MAX_MULTIPLIER,
7};
8
9fn close(a: f64, b: f64, tolerance: f64) {
10 assert!(
11 (a - b).abs() <= tolerance,
12 "expected {b} +/- {tolerance}, got {a}"
13 );
14}
15
16#[test]
17fn the_multiplier_doubles_every_five_seconds() {
18 close(multiplier_at(Duration::ZERO), 1.0, 1e-9);
19 close(multiplier_at(Duration::from_secs(5)), 2.0, 1e-9);
20 close(multiplier_at(Duration::from_secs(10)), 4.0, 1e-6);
21 close(multiplier_at(Duration::from_millis(2500)), 1.414_213_562, 1e-6);
22
23 // Never below 1x, even for nonsense input.
24 assert_eq!(multiplier_at_secs(-3.0), 1.0);
25 assert_eq!(multiplier_at_secs(f64::NAN), 1.0);
26 assert_eq!(multiplier_at_secs(10_000.0), MAX_MULTIPLIER);
27}
28
29#[test]
30fn the_time_for_a_multiplier_is_the_inverse_of_the_curve() {
31 close(time_for_multiplier(2.0).as_secs_f64(), 5.0, 1e-9);
32 close(time_for_multiplier(10.0).as_secs_f64(), 16.609_640, 1e-4);
33 close(multiplier_at(time_for_multiplier(3.5)), 3.5, 1e-9);
34}
35
36#[test]
37fn cashout_value_is_floored_to_whole_pln() {
38 assert_eq!(payout(10, 1.0), 10);
39 assert_eq!(payout(10, 1.05), 10, "half a PLN is not paid out");
40 assert_eq!(payout(10, 1.10), 11);
41 assert_eq!(payout(10, 2.47), 24);
42 assert_eq!(payout(7, 3.0), 21);
43
44 assert_eq!(payout(0, 2.0), 0);
45 assert_eq!(payout(10, f64::NAN), 0);
46 assert_eq!(payout(10, f64::INFINITY), 0);
47 assert_eq!(
48 payout(u64::MAX, MAX_MULTIPLIER),
49 u64::MAX,
50 "a huge win saturates instead of wrapping"
51 );
52}
53
54#[test]
55fn crash_points_follow_the_house_edge() {
56 // P(crash >= m) should be (1 - edge) / m.
57 let samples = 100_000;
58 for target in [1.5_f64, 2.0, 5.0, 50.0] {
59 let survivors = (0..samples)
60 .filter(|index| {
61 let u = *index as f64 / samples as f64;
62 crash_point_from_uniform(u) >= target
63 })
64 .count();
65 let observed = survivors as f64 / samples as f64;
66 let expected = (1.0 - HOUSE_EDGE) / target;
67 close(observed, expected, 0.002);
68 }
69}
70
71#[test]
72fn an_instant_cashout_still_pays_the_house_edge() {
73 // One round in a hundred crashes at 1.00x, so cashing out instantly returns
74 // the stake 99% of the time and nothing 1% of the time: no free money.
75 let samples = 100_000;
76 let instant_crashes = (0..samples)
77 .filter(|index| {
78 let u = *index as f64 / samples as f64;
79 crash_point_from_uniform(u) <= 1.0
80 })
81 .count();
82 close(instant_crashes as f64 / samples as f64, HOUSE_EDGE, 0.001);
83
84 let stake = 10;
85 let kept = payout(stake, 1.0);
86 assert_eq!(kept, stake);
87 let ev = kept as f64 * (1.0 - HOUSE_EDGE);
88 close(ev, stake as f64 * (1.0 - HOUSE_EDGE), 1e-9);
89}
90
91#[test]
92fn crash_points_are_clamped_to_the_liability_cap() {
93 assert_eq!(crash_point_from_uniform(0.0), 1.0);
94 assert_eq!(crash_point_from_uniform(-1.0), 1.0);
95 assert_eq!(crash_point_from_uniform(f64::NAN), 1.0);
96 close(crash_point_from_uniform(0.5), 1.98, 1e-9);
97 close(crash_point_from_uniform(0.99), 99.0, 1e-9);
98 assert_eq!(crash_point_from_uniform(0.999_999), MAX_MULTIPLIER);
99 assert_eq!(crash_point_from_uniform(1.0), MAX_MULTIPLIER);
100}
diff --git a/tests/server_core.rs b/tests/server_core.rs
index 08a2bf2..695cfe4 100644
--- a/tests/server_core.rs
+++ b/tests/server_core.rs
@@ -1,9 +1,12 @@
1use std::path::PathBuf; 1use std::path::PathBuf;
2use std::sync::Arc;
2 3
3use rusqlite::Connection; 4use rusqlite::Connection;
4use zagan_bet::blackjack::BlackjackState; 5use zagan_bet::blackjack::BlackjackState;
6use zagan_bet::crash::CrashState;
5use zagan_bet::server::core::{ConnectionState, ServerCore}; 7use zagan_bet::server::core::{ConnectionState, ServerCore};
6use zagan_bet::server::database; 8use zagan_bet::server::database;
9use zagan_bet::server::games::crash::ManualClock;
7use zagan_bet::server::games::registry::GameRegistry; 10use zagan_bet::server::games::registry::GameRegistry;
8use zagan_bet::server::games::roulette::RouletteGame; 11use zagan_bet::server::games::roulette::RouletteGame;
9use zagan_bet::server::games::slots::SlotsGame; 12use zagan_bet::server::games::slots::SlotsGame;
@@ -73,6 +76,31 @@ fn blackjack_state(response: &str) -> BlackjackState {
73 serde_json::from_str(raw).expect("valid blackjack state") 76 serde_json::from_str(raw).expect("valid blackjack state")
74} 77}
75 78
79fn setup_crash(name: &str, uniform: f64) -> (ServerCore, String, ManualClock) {
80 let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR"))
81 .join(format!("server-core-{name}.db"))
82 .to_string_lossy()
83 .into_owned();
84 let _ = std::fs::remove_file(&path);
85
86 let conn = Connection::open(&path).unwrap();
87 database::init_db(&conn);
88
89 let clock = ManualClock::new();
90 let core = ServerCore::with_registry(conn, GameRegistry::with_defaults(), 4)
91 .with_crash_uniform(uniform)
92 .with_clock(Arc::new(clock.clone()));
93 (core, path, clock)
94}
95
96fn crash_state(response: &str) -> CrashState {
97 let raw = ["CRASH_START ", "CRASH_STATE ", "CRASH_BUSTED "]
98 .iter()
99 .find_map(|prefix| response.strip_prefix(prefix))
100 .unwrap_or_else(|| panic!("not a crash response: {response}"));
101 serde_json::from_str(raw).expect("valid crash state")
102}
103
76fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { 104fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
77 assert_eq!( 105 assert_eq!(
78 core.handle_message(state, &format!("REGISTER {user} credential")), 106 core.handle_message(state, &format!("REGISTER {user} credential")),
@@ -496,3 +524,220 @@ fn blackjack_disconnect_forfeits_the_reserved_bet() {
496 assert_eq!(core.play_count("alice"), 1); 524 assert_eq!(core.play_count("alice"), 1);
497 assert_eq!(core.session_count(), 0); 525 assert_eq!(core.session_count(), 0);
498} 526}
527
528// 0.67 crashes at 3.00x: (1 - 0.01) / (1 - 0.67) = 3.0.
529const CRASH_AT_3: f64 = 0.67;
530
531#[test]
532fn crash_start_reserves_the_bet_and_hides_the_crash_point() {
533 let (mut core, _path, _clock) = setup_crash("crash-start", CRASH_AT_3);
534 let mut state = ConnectionState::default();
535 register(&mut core, &mut state, "alice");
536 login(&mut core, &mut state, "alice");
537
538 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
539
540 assert_eq!(started.phase, "running");
541 assert_eq!(started.bet, 10);
542 assert_eq!(started.balance, 990);
543 assert_eq!(started.multiplier, 1.0);
544 assert_eq!(started.potential, 10);
545 assert_eq!(started.payout, 0);
546 assert_eq!(
547 started.crash_point, None,
548 "the crash point must not leak before the round ends"
549 );
550 assert_eq!(core.balance("alice"), Some(990));
551 assert_eq!(core.session_count(), 1);
552 assert_eq!(core.play_count("alice"), 0);
553}
554
555#[test]
556fn crash_cashout_pays_the_multiplier_from_the_server_clock() {
557 let (mut core, path, clock) = setup_crash("crash-cashout", CRASH_AT_3);
558 let mut state = ConnectionState::default();
559 register(&mut core, &mut state, "alice");
560 login(&mut core, &mut state, "alice");
561
562 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
563
564 // Five seconds of round time: 2.00x, still below the 3.00x crash point.
565 clock.advance(5.0);
566 let cashed = crash_state(&core.handle_message(
567 &mut state,
568 &format!("CRASH_CASHOUT {}", started.session_id),
569 ));
570
571 assert_eq!(cashed.phase, "cashed");
572 assert_eq!(cashed.multiplier, 2.0);
573 assert_eq!(cashed.potential, 20);
574 assert_eq!(cashed.payout, 20);
575 assert_eq!(cashed.balance, 1010);
576 let crash_point = cashed.crash_point.unwrap();
577 assert!((crash_point - 3.0).abs() < 1e-9, "got {crash_point}");
578 assert_eq!(core.balance("alice"), Some(1010));
579 assert_eq!(core.play_count("alice"), 1);
580 assert_eq!(core.session_count(), 0);
581
582 let conn = Connection::open(&path).unwrap();
583 let (game_type, bet, winnings, balance_after, details): (String, i64, i64, i64, String) = conn
584 .query_row(
585 "SELECT game_type, bet_amount, winnings, balance_after, details
586 FROM plays WHERE username = 'alice'",
587 [],
588 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
589 )
590 .unwrap();
591 assert_eq!(game_type, "crash");
592 assert_eq!(bet, 10);
593 assert_eq!(winnings, 20);
594 assert_eq!(balance_after, 1010);
595 let details: serde_json::Value = serde_json::from_str(&details).unwrap();
596 assert_eq!(details["phase"], "cashed");
597 let audited_crash_point = details["crash_point"].as_f64().unwrap();
598 assert!(
599 (audited_crash_point - 3.0).abs() < 1e-9,
600 "got {audited_crash_point}"
601 );
602 assert_eq!(details["multiplier"], 2.0);
603}
604
605#[test]
606fn a_cashout_after_the_crash_time_loses_the_reserved_bet() {
607 let (mut core, _path, clock) = setup_crash("crash-too-late", CRASH_AT_3);
608 let mut state = ConnectionState::default();
609 register(&mut core, &mut state, "alice");
610 login(&mut core, &mut state, "alice");
611
612 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
613
614 clock.advance(20.0);
615 let late = crash_state(&core.handle_message(
616 &mut state,
617 &format!("CRASH_CASHOUT {}", started.session_id),
618 ));
619
620 assert_eq!(late.phase, "crashed");
621 assert_eq!(late.payout, 0);
622 assert_eq!(late.balance, 990);
623 let crash_point = late.crash_point.unwrap();
624 assert!((crash_point - 3.0).abs() < 1e-9, "got {crash_point}");
625 assert_eq!(core.balance("alice"), Some(990));
626 assert_eq!(core.play_count("alice"), 1);
627 assert_eq!(core.session_count(), 0);
628}
629
630#[test]
631fn crash_poll_pushes_the_bust_exactly_once() {
632 let (mut core, _path, clock) = setup_crash("crash-poll", CRASH_AT_3);
633 let mut state = ConnectionState::default();
634 register(&mut core, &mut state, "alice");
635 login(&mut core, &mut state, "alice");
636
637 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
638
639 clock.advance(1.0);
640 assert!(
641 core.poll(&state).is_empty(),
642 "a running round must not push anything"
643 );
644
645 clock.advance(15.0);
646 let pushed = core.poll(&state);
647 assert_eq!(pushed.len(), 1);
648 let busted = crash_state(&pushed[0]);
649 assert_eq!(busted.session_id, started.session_id);
650 assert_eq!(busted.phase, "crashed");
651 assert_eq!(busted.payout, 0);
652 assert_eq!(busted.balance, 990);
653
654 assert!(core.poll(&state).is_empty(), "the bust must not repeat");
655 assert_eq!(core.play_count("alice"), 1);
656 assert_eq!(core.session_count(), 0);
657}
658
659#[test]
660fn crash_forfeit_keeps_the_reserved_bet() {
661 let (mut core, _path, _clock) = setup_crash("crash-forfeit", CRASH_AT_3);
662 let mut state = ConnectionState::default();
663 register(&mut core, &mut state, "alice");
664 login(&mut core, &mut state, "alice");
665
666 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
667 let forfeited = crash_state(&core.handle_message(
668 &mut state,
669 &format!("CRASH_FORFEIT {}", started.session_id),
670 ));
671
672 assert_eq!(forfeited.phase, "forfeit");
673 assert_eq!(forfeited.payout, 0);
674 assert_eq!(core.balance("alice"), Some(990));
675 assert_eq!(core.play_count("alice"), 1);
676 assert_eq!(core.session_count(), 0);
677}
678
679#[test]
680fn crash_disconnect_forfeits_the_reserved_bet() {
681 let (mut core, _path, _clock) = setup_crash("crash-disconnect", CRASH_AT_3);
682 let mut state = ConnectionState::default();
683 register(&mut core, &mut state, "alice");
684 login(&mut core, &mut state, "alice");
685
686 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
687 assert_eq!(started.balance, 990);
688
689 core.disconnect(&state);
690
691 assert_eq!(core.balance("alice"), Some(990));
692 assert_eq!(core.play_count("alice"), 1);
693 assert_eq!(core.session_count(), 0);
694}
695
696#[test]
697fn crash_rejects_a_second_round_and_bad_cashout_formats() {
698 let (mut core, _path, _clock) = setup_crash("crash-guards", CRASH_AT_3);
699 let mut state = ConnectionState::default();
700 register(&mut core, &mut state, "alice");
701 login(&mut core, &mut state, "alice");
702
703 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
704 assert_eq!(
705 core.handle_message(&mut state, "PLAY_START crash 10"),
706 "ERROR Crash round already in progress"
707 );
708 assert_eq!(
709 core.handle_message(
710 &mut state,
711 &format!("CRASH_CASHOUT {} 50.0", started.session_id)
712 ),
713 "ERROR Invalid CRASH_CASHOUT format. Usage: CRASH_CASHOUT <session>"
714 );
715 assert_eq!(
716 core.handle_message(&mut state, "CRASH_CASHOUT nope"),
717 "ERROR Session not found"
718 );
719}
720
721#[test]
722fn crash_rounds_are_owned_by_the_player_who_started_them() {
723 let (mut core, _path, clock) = setup_crash("crash-owner", CRASH_AT_3);
724 let mut alice = ConnectionState::default();
725 let mut bob = ConnectionState::default();
726 register(&mut core, &mut alice, "alice");
727 register(&mut core, &mut bob, "bob");
728 login(&mut core, &mut alice, "alice");
729 login(&mut core, &mut bob, "bob");
730
731 let started = crash_state(&core.handle_message(&mut alice, "PLAY_START crash 10"));
732
733 clock.advance(5.0);
734 assert_eq!(
735 core.handle_message(
736 &mut bob,
737 &format!("CRASH_CASHOUT {}", started.session_id)
738 ),
739 "ERROR Invalid session"
740 );
741 assert_eq!(core.balance("bob"), Some(1000));
742 assert_eq!(core.session_count(), 1, "alice's round must survive");
743}