From 058432130628d00764663ecf819a9adc6793768d Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 8 Nov 2026 03:29:35 +0100 Subject: v5.0.0: protocol v5 and the browser frontend Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point. Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials. Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph. The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO. --- tests/client_core.rs | 151 ++++++++++++++++++++++++++++++++++++--------------- tests/crash.rs | 39 +++++++++++++ tests/protocol.rs | 150 +++++++++++++++++++++++++++++++++----------------- tests/server_core.rs | 121 +++++++++++++++++++++++++++++++++++------ tests/version.rs | 57 ++++++++++++++++--- tests/web_types.rs | 138 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 538 insertions(+), 118 deletions(-) create mode 100644 tests/web_types.rs (limited to 'tests') diff --git a/tests/client_core.rs b/tests/client_core.rs index 2f43d47..3a2fb27 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs @@ -7,17 +7,17 @@ use std::time::Duration; use zagan_bet::blackjack::BlackjackState; use zagan_bet::client::core::{ClientCore, Effect}; -use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; -use zagan_bet::client::shell::{self, Shell}; -use zagan_bet::client::ui::help_lines; use zagan_bet::client::games::response::{ parse_blackjack_response, parse_crash_response, parse_spin_response, }; use zagan_bet::client::games::roulette::{Cursor, Direction}; +use zagan_bet::client::input::{Editor, GameInput, Input, Key, game_input}; +use zagan_bet::client::shell::{self, Shell}; use zagan_bet::client::ui::command_names; +use zagan_bet::client::ui::help_lines; use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View}; -use zagan_bet::roulette::BetTarget; use zagan_bet::crash::{CrashState, multiplier_at}; +use zagan_bet::roulette::BetTarget; const WIN3: &str = "CCCCLLCLODGSDGS"; const LOSS: &str = "CLOOGDSLCDCOGLS"; @@ -54,7 +54,7 @@ impl Screen { self.region_start = None; self.region_len = 0; } - Effect::SendLine(line) => sent.push(line.clone()), + Effect::SendRequest { payload, .. } => sent.push(payload.clone()), Effect::Quit | Effect::Disconnect => {} } } @@ -601,7 +601,7 @@ fn ping_prints_pong() { h.line("ping"); assert_eq!(h.sent.last().map(String::as_str), Some("PING")); - h.reply("PONG"); + h.reply("PONG 1700000000000"); assert!(h.screen.lines.iter().any(|line| line == "Pong")); } @@ -968,6 +968,13 @@ fn crash_state( crash_point, payout, result: phase.to_string(), + started_at: 1_700_000_000_000, + commit: Some("00".repeat(32)), + seed: if phase == "running" { + None + } else { + Some("00".repeat(32)) + }, } } @@ -1221,11 +1228,12 @@ fn history_prints_recent_plays() { let json = r#"[{"game":"roulette","bet":10,"winnings":20,"balance_after":1010,"at":"2026-10-30 01:20:11","details":{"number":"17","color":"black"}}]"#; h.reply(&format!("HISTORY {json}")); - assert!(h - .screen - .lines - .iter() - .any(|line| line.starts_with("Recent plays (1)"))); + assert!( + h.screen + .lines + .iter() + .any(|line| line.starts_with("Recent plays (1)")) + ); let row = h .screen .lines @@ -1242,11 +1250,12 @@ fn history_without_plays_says_so() { let mut h = Harness::new(); h.line("history"); h.reply("HISTORY []"); - assert!(h - .screen - .lines - .iter() - .any(|line| line == "No plays recorded yet.")); + assert!( + h.screen + .lines + .iter() + .any(|line| line == "No plays recorded yet.") + ); } #[test] @@ -1254,11 +1263,12 @@ fn history_rejects_a_bad_count_without_sending() { let mut h = Harness::new(); h.line("history 0"); assert!(!h.sent.iter().any(|line| line == "HISTORY 0")); - assert!(h - .screen - .lines - .iter() - .any(|line| line.contains("Usage: history [1-50]"))); + assert!( + h.screen + .lines + .iter() + .any(|line| line.contains("Usage: history [1-50]")) + ); } #[test] @@ -1291,10 +1301,7 @@ fn the_roulette_view_reflects_the_cursor_and_bets() { Some(BetTarget::Straight { number: 2 }) ); assert_eq!(view.bets.total(), 10); - assert_eq!( - view.bets.amount_on(&BetTarget::Straight { number: 2 }), - 10 - ); + assert_eq!(view.bets.amount_on(&BetTarget::Straight { number: 2 }), 10); } #[test] @@ -1302,7 +1309,9 @@ fn clicking_a_bet_target_does_not_move_the_cursor() { let mut h = Harness::new(); h.enter_roulette(); - let effects = h.core.command(Command::PlaceBetAt(BetTarget::Split { a: 1, b: 4 })); + let effects = h + .core + .command(Command::PlaceBetAt(BetTarget::Split { a: 1, b: 4 })); h.apply(effects); let Some(View::Roulette(view)) = h.core.view() else { @@ -1387,9 +1396,7 @@ fn the_slots_view_carries_the_result() { let mut h = Harness::new(); h.enter_slots("10"); h.key(Key::Char(' ')); - h.reply(&format!( - "GAME_RESULT s1 20 10 1010 {WIN3} win 1 0,3,6" - )); + h.reply(&format!("GAME_RESULT s1 20 10 1010 {WIN3} win 1 0,3,6")); let Some(View::Slots(view)) = h.core.view() else { panic!("expected a slots view"); @@ -1518,11 +1525,12 @@ fn history_is_a_screen_and_keeps_its_records() { assert_eq!(view.records[0].summary(), "cashed 1.13x"); // The terminal adapter still prints the table. - assert!(h - .screen - .lines - .iter() - .any(|line| line.starts_with("Recent plays (1)"))); + assert!( + h.screen + .lines + .iter() + .any(|line| line.starts_with("Recent plays (1)")) + ); } #[test] @@ -1563,11 +1571,12 @@ fn a_login_command_sends_auth_and_sets_the_user() { }; assert_eq!(lobby.user.as_deref(), Some("alice")); assert!(lobby.login_error.is_none()); - assert!(h - .screen - .lines - .iter() - .any(|line| line == "Logged in as alice")); + assert!( + h.screen + .lines + .iter() + .any(|line| line == "Logged in as alice") + ); } #[test] @@ -1600,11 +1609,12 @@ fn a_register_command_sends_register_and_reports_the_result() { assert_eq!(h.sent.last().map(String::as_str), Some("REGISTER bob hash")); h.reply("REGISTERED bob"); - assert!(h - .screen - .lines - .iter() - .any(|line| line == "Account created. Log in to play.")); + assert!( + h.screen + .lines + .iter() + .any(|line| line == "Account created. Log in to play.") + ); } #[test] @@ -1646,3 +1656,56 @@ fn the_crash_multiplier_is_not_rounded_for_the_chart() { "a two-decimal store would flatten the curve into steps" ); } + +#[test] +fn a_reply_is_routed_by_request_id_and_late_replies_are_ignored() { + let mut h = Harness::new(); + let first = h.core.command(Command::Ping); + let first_id = first + .iter() + .find_map(|effect| match effect { + Effect::SendRequest { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + + let second = h.core.command(Command::Info); + let second_id = second + .iter() + .find_map(|effect| match effect { + Effect::SendRequest { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + assert_ne!(first_id, second_id, "ids must be unique on the connection"); + + // The ping reply arrives after INFO was sent. It must not complete INFO. + assert!( + h.core + .handle_reply_id(first_id, "PONG 1700000000000") + .is_empty() + ); + let effects = h.core.handle_reply_id(second_id, "INFO alice 1 1000"); + assert!( + effects.iter().any(|effect| matches!(effect, Effect::Print(lines) if lines.iter().any(|line| line.contains("Balance: 1000")))), + "the matching reply is applied" + ); +} + +#[test] +fn a_crash_tick_updates_the_board_for_its_own_session() { + let mut h = Harness::new(); + h.enter_crash(); + h.key(Key::Char(' ')); + let running = crash_state("running", 1.0, None, 0, 990); + h.reply(&crash_line("CRASH_START", &running)); + assert!(h.board()[2].contains("Multiplier: 1.00x")); + + h.push("crash.tick session=cr1 t=1700000000100 m=1.5"); + assert!(h.board()[2].contains("Multiplier: 1.50x")); + assert!(h.board()[3].contains("Potential: 15 Kr")); + + // A tick for another round cannot move this one. + h.push("crash.tick session=other t=1700000000200 m=9.0"); + assert!(h.board()[2].contains("Multiplier: 1.50x")); +} diff --git a/tests/crash.rs b/tests/crash.rs index 797874b..513f9c8 100644 --- a/tests/crash.rs +++ b/tests/crash.rs @@ -102,3 +102,42 @@ fn crash_points_are_clamped_to_the_liability_cap() { assert_eq!(crash_point_from_uniform(0.999_999), MAX_MULTIPLIER); assert_eq!(crash_point_from_uniform(1.0), MAX_MULTIPLIER); } + +#[test] +fn a_seed_commits_to_its_crash_point_and_can_be_verified() { + use zagan_bet::crash::{ + SEED_LEN, commit_seed, crash_point_from_seed, seed_from_hex, seed_to_uniform, verify_seed, + }; + + let mut seed = [0u8; SEED_LEN]; + let value = (0.67_f64 * 18_446_744_073_709_551_616.0) as u64; + seed[..8].copy_from_slice(&value.to_be_bytes()); + let commit = commit_seed(&seed); + let hex = format!( + "{}", + seed.iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + + assert_eq!(seed_from_hex(&hex).as_deref(), Some(seed.as_slice())); + assert!(verify_seed(&hex, &commit)); + assert!(!verify_seed(&hex, &"00".repeat(32)), "a wrong commit fails"); + assert!(!verify_seed("nope", &commit), "bad hex fails"); + + // The point the client recomputes is the point the server committed to. + close(crash_point_from_seed(&seed), 3.0, 1e-9); + close(crash_point_from_seed(&seed), 3.0, 1e-9); + + // The first eight bytes are the uniform; the rest are still hashed. + close(seed_to_uniform(&seed), 0.67, 1e-9); + let mut altered = seed; + altered[SEED_LEN - 1] = 1; + assert!(!verify_seed( + &altered + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(), + &commit + )); +} diff --git a/tests/protocol.rs b/tests/protocol.rs index bf8a4fd..d829ba9 100644 --- a/tests/protocol.rs +++ b/tests/protocol.rs @@ -1,84 +1,136 @@ -use std::io::{Cursor, Error, ErrorKind}; +use std::io::Cursor; +use zagan_bet::protocol::{ + FrameBuffer, FrameError, MAX_FRAME_LEN, Packet, encode_frame, event_line, io_failure_message, + parse_packet, parse_request, reply_line, request_line, +}; -use zagan_bet::protocol::{io_failure_message, LineBuffer}; +fn framed(payload: &str) -> Vec { + encode_frame(payload) +} #[test] -fn keeps_data_after_the_first_newline() { - let mut buffer = LineBuffer::new(); - buffer.push(b"PONG\nGAME_RESULT session 60 10 990 OSS two\n"); +fn decodes_every_complete_frame_and_keeps_the_remainder() { + let mut buffer = FrameBuffer::new(); + let mut bytes = framed("RES 1 PONG 1700000000000"); + bytes.extend(framed("EVT 2 crash.tick session=cr1 t=1700000000100 m=1.5")); + buffer.push(&bytes); - assert_eq!(buffer.next_line().as_deref(), Some("PONG")); assert_eq!( - buffer.next_line().as_deref(), - Some("GAME_RESULT session 60 10 990 OSS two") + buffer.next_frame().unwrap().unwrap(), + "RES 1 PONG 1700000000000" + ); + assert_eq!( + buffer.next_frame().unwrap().unwrap(), + "EVT 2 crash.tick session=cr1 t=1700000000100 m=1.5" ); - assert_eq!(buffer.next_line(), None); + assert!(buffer.next_frame().is_none()); + assert!(buffer.is_empty()); } #[test] -fn handles_crlf_and_empty_lines() { - let mut buffer = LineBuffer::new(); - buffer.push(b"PONG\r\n\r\n"); +fn waits_for_a_partial_frame() { + let mut buffer = FrameBuffer::new(); + let bytes = framed("RES 7 OK"); + buffer.push(&bytes[..3]); + assert!(buffer.next_frame().is_none()); - assert_eq!(buffer.next_line().as_deref(), Some("PONG")); - assert_eq!(buffer.next_line().as_deref(), Some("")); - assert_eq!(buffer.next_line(), None); + buffer.push(&bytes[3..7]); + assert!(buffer.next_frame().is_none()); + + buffer.push(&bytes[7..]); + assert_eq!(buffer.next_frame().unwrap().unwrap(), "RES 7 OK"); } #[test] -fn waits_for_a_split_line() { - let mut buffer = LineBuffer::new(); - buffer.push(b"PO"); - assert_eq!(buffer.next_line(), None); +fn an_oversized_length_is_rejected_without_waiting_for_bytes() { + let mut buffer = FrameBuffer::new(); + buffer.push(&((MAX_FRAME_LEN as u32 + 1).to_be_bytes())); + match buffer.next_frame() { + Some(Err(FrameError::TooLarge(length))) => { + assert_eq!(length, MAX_FRAME_LEN + 1); + } + other => panic!("expected an oversize error, got {other:?}"), + } + assert!(buffer.is_empty(), "the buffer must not keep the bad frame"); +} - buffer.push(b"NG\n"); - assert_eq!(buffer.next_line().as_deref(), Some("PONG")); +#[test] +fn invalid_utf8_is_a_protocol_error() { + let mut buffer = FrameBuffer::new(); + buffer.push(&1u32.to_be_bytes()); + buffer.push(&[0xff]); + assert_eq!(buffer.next_frame(), Some(Err(FrameError::InvalidUtf8))); } #[test] -fn reads_multiple_lines_from_a_reader() { - let mut buffer = LineBuffer::new(); - let mut reader = Cursor::new(b"one\ntwo\n".to_vec()); +fn reads_frames_from_a_blocking_reader() { + let mut bytes = framed("RES 1 OK"); + bytes.extend(framed("EVT 1 crash.tick session=cr1 t=1 m=1.0")); + let mut reader = Cursor::new(bytes); + let mut buffer = FrameBuffer::new(); assert_eq!( - buffer.read_line(&mut reader).unwrap().as_deref(), - Some("one") + buffer.read_frame(&mut reader).unwrap().as_deref(), + Some("RES 1 OK") ); assert_eq!( - buffer.read_line(&mut reader).unwrap().as_deref(), - Some("two") + buffer.read_frame(&mut reader).unwrap().as_deref(), + Some("EVT 1 crash.tick session=cr1 t=1 m=1.0") ); - assert_eq!(buffer.read_line(&mut reader).unwrap(), None); + assert_eq!(buffer.read_frame(&mut reader).unwrap(), None); } #[test] -fn returns_a_partial_line_at_eof() { - let mut buffer = LineBuffer::new(); - let mut reader = Cursor::new(b"partial".to_vec()); - +fn builds_and_parses_the_envelope() { assert_eq!( - buffer.read_line(&mut reader).unwrap().as_deref(), - Some("partial") + request_line(42, None, "PLAY_START crash 50"), + "REQ 42 PLAY_START crash 50" ); - assert_eq!(buffer.read_line(&mut reader).unwrap(), None); + assert_eq!( + request_line(42, Some("abc123"), "PLAY_START crash 50"), + "REQ 42 idem=abc123 PLAY_START crash 50" + ); + + let request = parse_request("REQ 42 idem=abc123 PLAY_START crash 50").unwrap(); + assert_eq!(request.id, 42); + assert_eq!(request.idem.as_deref(), Some("abc123")); + assert_eq!(request.payload, "PLAY_START crash 50"); + + let request = parse_request("REQ 7 PING").unwrap(); + assert_eq!(request.id, 7); + assert_eq!(request.idem, None); + assert_eq!(request.payload, "PING"); + + assert!(parse_request("PLAY_START crash 50").is_err()); + assert!(parse_request("REQ nope PING").is_err()); + assert!(parse_request("REQ 1 idem= PING").is_err()); } #[test] -fn timeout_errors_get_a_timeout_message() { - assert_eq!( - io_failure_message(&Error::from(ErrorKind::TimedOut)), - "ERROR Request timed out" - ); - assert_eq!( - io_failure_message(&Error::from(ErrorKind::WouldBlock)), - "ERROR Request timed out" - ); +fn parses_replies_and_events() { assert_eq!( - io_failure_message(&Error::from(ErrorKind::UnexpectedEof)), - "ERROR Connection lost" + parse_packet(&reply_line(9, "ERROR Insufficient balance")).unwrap(), + Packet::Reply { + id: 9, + body: "ERROR Insufficient balance".to_string() + } ); assert_eq!( - io_failure_message(&Error::from(ErrorKind::ConnectionReset)), - "ERROR Connection lost" + parse_packet(&event_line(3, "CRASH_BUSTED {}")).unwrap(), + Packet::Event { + seq: 3, + body: "CRASH_BUSTED {}".to_string() + } ); + assert!(parse_packet("REQ 1 PING").is_err()); + assert!(parse_packet("RES nope OK").is_err()); +} + +#[test] +fn io_failures_are_reported_as_short_messages() { + let timeout = std::io::Error::new(std::io::ErrorKind::TimedOut, "slow"); + assert_eq!(io_failure_message(&timeout), "ERROR Request timed out"); + + let closed = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "gone"); + assert_eq!(io_failure_message(&closed), "ERROR Connection lost"); } diff --git a/tests/server_core.rs b/tests/server_core.rs index e2600e4..6c9c835 100644 --- a/tests/server_core.rs +++ b/tests/server_core.rs @@ -101,11 +101,11 @@ fn crash_state(response: &str) -> CrashState { } fn hello(core: &mut ServerCore, state: &mut ConnectionState) { - let response = core.handle_message( - state, - &format!("HELLO {}", zagan_bet::version::VERSION), + let response = core.handle_message(state, &format!("HELLO {}", zagan_bet::version::VERSION)); + assert!( + response.starts_with("HELLO "), + "handshake failed: {response}" ); - assert!(response.starts_with("HELLO "), "handshake failed: {response}"); } fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { @@ -258,8 +258,18 @@ fn roulette_settles_split_bets_and_records_details() { let mut bets = Bets::default(); bets.add(BetTarget::Outside { kind: Outside::Red }, 10); - bets.add(BetTarget::Outside { kind: Outside::Black }, 10); - bets.add(BetTarget::Outside { kind: Outside::Even }, 10); + bets.add( + BetTarget::Outside { + kind: Outside::Black, + }, + 10, + ); + bets.add( + BetTarget::Outside { + kind: Outside::Even, + }, + 10, + ); bets.add(BetTarget::Outside { kind: Outside::Odd }, 10); bets.add(BetTarget::Straight { number: 17 }, 5); let bets = bets.to_json(); @@ -671,10 +681,14 @@ fn crash_poll_pushes_the_bust_exactly_once() { let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); clock.advance(1.0); + let ticks = core.poll(&state); + assert_eq!(ticks.len(), 1, "a running round pushes coalesced ticks"); assert!( - core.poll(&state).is_empty(), - "a running round must not push anything" + ticks[0].starts_with("crash.tick session="), + "tick: {}", + ticks[0] ); + assert!(ticks[0].contains(" m="), "tick carries the multiplier"); clock.advance(15.0); let pushed = core.poll(&state); @@ -920,9 +934,10 @@ fn history_lists_recent_plays_newest_first() { login(&mut core, &mut state, "alice"); for _ in 0..3 { - assert!(core - .handle_message(&mut state, "PLAY_START slots 10") - .starts_with("GAME_RESULT")); + assert!( + core.handle_message(&mut state, "PLAY_START slots 10") + .starts_with("GAME_RESULT") + ); } let response = core.handle_message(&mut state, "HISTORY 2"); @@ -979,7 +994,10 @@ fn hello_reports_the_version_and_download_url() { assert!(state.greeted()); // The gate is open now. - assert_eq!(core.handle_message(&mut state, "PING"), "PONG"); + assert!( + core.handle_message(&mut state, "PING").starts_with("PONG "), + "PING must carry the server time" + ); } #[test] @@ -999,7 +1017,10 @@ fn hello_refuses_a_different_major_or_minor() { response.starts_with("ERROR Version mismatch"), "response: {response}" ); - assert!(!state.greeted(), "a refused handshake must not open the gate"); + assert!( + !state.greeted(), + "a refused handshake must not open the gate" + ); assert_eq!( core.handle_message(&mut state, "PING"), "ERROR HELLO required" @@ -1014,9 +1035,10 @@ fn hello_accepts_a_patch_difference_and_rejects_bad_input() { let patch = format!("{}.{}.{}", server.major, server.minor, server.patch + 1); let mut state = ConnectionState::default(); - assert!(core - .handle_message(&mut state, &format!("HELLO {patch}")) - .starts_with("HELLO ")); + assert!( + core.handle_message(&mut state, &format!("HELLO {patch}")) + .starts_with("HELLO ") + ); let mut other = ConnectionState::default(); assert_eq!( @@ -1028,3 +1050,70 @@ fn hello_accepts_a_patch_difference_and_rejects_bad_input() { "ERROR Invalid version" ); } + +#[test] +fn crash_start_commits_to_a_seed_and_the_end_reveals_it() { + use zagan_bet::crash::{crash_point_from_seed, seed_from_hex, verify_seed}; + + let (mut core, _path, clock) = setup_crash("crash-commit", CRASH_AT_3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); + let commit = started.commit.clone().expect("a start reply commits"); + assert_eq!(commit.len(), 64, "sha256 hex"); + assert_eq!(started.seed, None, "the seed stays hidden while running"); + assert_eq!(started.crash_point, None); + assert!(started.started_at > 1_600_000_000_000, "server time anchor"); + + clock.advance(5.0); + let cashed = crash_state( + &core.handle_message(&mut state, &format!("CRASH_CASHOUT {}", started.session_id)), + ); + let seed = cashed.seed.clone().expect("the end reveals the seed"); + assert!(verify_seed(&seed, &commit), "the reveal matches the commit"); + let bytes = seed_from_hex(&seed).expect("32-byte hex seed"); + let point = crash_point_from_seed(&bytes); + assert!( + (point - cashed.crash_point.unwrap()).abs() < 1e-9, + "the client recomputes the same point" + ); + assert_eq!(cashed.commit.as_deref(), Some(commit.as_str())); +} + +#[test] +fn a_repeated_request_id_replays_its_reply_without_charging_twice() { + use zagan_bet::protocol::Request; + + let (mut core, _path) = setup("idempotent-spin", WIN3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + let request = Request { + id: 41, + idem: Some("spin-1".to_string()), + payload: "PLAY_START slots 10".to_string(), + }; + let first = core.handle_request(&mut state, &request); + assert!(first.starts_with("GAME_RESULT"), "first: {first}"); + assert_eq!(core.balance("alice"), Some(1000)); + assert_eq!(core.play_count("alice"), 1); + + let replay = core.handle_request(&mut state, &request); + assert_eq!(replay, first, "the cached reply is returned verbatim"); + assert_eq!(core.balance("alice"), Some(1000), "no second debit"); + assert_eq!(core.play_count("alice"), 1, "no second audit row"); + + let different = Request { + id: 41, + idem: Some("spin-2".to_string()), + payload: "PLAY_START slots 20".to_string(), + }; + assert_eq!( + core.handle_request(&mut state, &different), + "ERROR Request id reused with a different command" + ); + assert_eq!(core.balance("alice"), Some(1000)); +} diff --git a/tests/version.rs b/tests/version.rs index af244db..31bc2b6 100644 --- a/tests/version.rs +++ b/tests/version.rs @@ -1,5 +1,6 @@ use zagan_bet::version::{ - compatible, hello_line, is_newer, parse_hello, Version, VERSION, + VERSION, Version, compatible, hello_line, is_newer, parse_hello, parse_pong, pong_line, + unix_time_ms, }; #[test] @@ -24,16 +25,16 @@ fn versions_parse_and_reject_nonsense() { Version::parse("4.0.0-rc1").map(|v| (v.major, v.minor, v.patch)), Some((4, 0, 0)) ); - assert_eq!( - Version::parse("4.0.0+build7").map(|v| v.patch), - Some(0) - ); + assert_eq!(Version::parse("4.0.0+build7").map(|v| v.patch), Some(0)); for bad in ["", "4", "4.0", "4.0.0.1", "x.0.0", "4.x.0", "4.0.0-"] { assert!(Version::parse(bad).is_none(), "{bad:?} should not parse"); } - assert!(Version::parse(VERSION).is_some(), "the build version must parse"); + assert!( + Version::parse(VERSION).is_some(), + "the build version must parse" + ); } #[test] @@ -57,19 +58,45 @@ fn newer_versions_are_ordered_by_major_minor_patch() { #[test] fn the_hello_reply_round_trips() { - let line = hello_line("4.0.0", "4.1.2", "https://zagan.example/"); + let line = hello_line( + "4.0.0", + "4.1.2", + "https://zagan.example/", + 1_700_000_000_123, + ); let reply = parse_hello(&line).expect("parse a generated reply"); assert_eq!(reply.version, "4.0.0"); assert_eq!(reply.latest, "4.1.2"); assert_eq!(reply.url, "https://zagan.example/"); + assert_eq!(reply.time, 1_700_000_000_123); - assert!(parse_hello("HELLO 4.0.0 url=https://x/").is_none(), "latest is required"); - assert!(parse_hello("HELLO 4.0.0 latest=4.0.0").is_none(), "url is required"); + assert!( + parse_hello("HELLO 4.0.0 url=https://x/ time=1").is_none(), + "latest is required" + ); + assert!( + parse_hello("HELLO 4.0.0 latest=4.0.0 time=1").is_none(), + "url is required" + ); + assert!( + parse_hello("HELLO 4.0.0 latest=4.0.0 url=https://x/").is_none(), + "time is required" + ); assert!(parse_hello("PONG").is_none()); assert!(parse_hello("HELLO").is_none()); } +#[test] +fn pong_carries_the_server_time() { + let line = pong_line(1_700_000_000_456); + assert_eq!(line, "PONG 1700000000456"); + assert_eq!(parse_pong(&line), Some(1_700_000_000_456)); + assert!(parse_pong("PONG").is_none()); + assert!(parse_pong("RES 1 PONG 1700000000456").is_none()); + assert!(unix_time_ms() > 1_600_000_000_000); +} + /// The GUI is a client, so it must ship in lockstep with the TUI and server. /// Keep `gui/Cargo.toml` at the same version as the root package. #[test] @@ -83,6 +110,18 @@ fn the_gui_member_uses_the_same_version() { assert_eq!(gui_version, VERSION); } +/// The gateway is a frontend too; keep it in lockstep with the core. +#[test] +fn the_webd_member_uses_the_same_version() { + let manifest = include_str!("../webd/Cargo.toml"); + let version = manifest + .lines() + .find_map(|line| line.trim().strip_prefix("version = ")) + .expect("webd/Cargo.toml declares a version") + .trim_matches('"'); + assert_eq!(version, VERSION); +} + /// egui's dependency tree must never enter the TUI or server build graph. /// The GUI is a workspace member that depends on this package, never the /// other way round. diff --git a/tests/web_types.rs b/tests/web_types.rs new file mode 100644 index 0000000..21af474 --- /dev/null +++ b/tests/web_types.rs @@ -0,0 +1,138 @@ +//! The browser and gateway exchange serde JSON for `Command`/`View`. These +//! tests pin the wire shape the TypeScript app renders. + +use zagan_bet::client::games::roulette::{Cursor, Direction}; +use zagan_bet::client::view::{ + BlackjackView, Command, CrashView, HistoryView, LobbyView, RoulettePhase, RouletteView, + SlotsPhase, SlotsView, View, +}; +use zagan_bet::roulette::{BetTarget, Bets, Outside}; + +#[test] +fn browser_commands_deserialize() { + let login: Command = + serde_json::from_str(r#"{"Login":{"user":"alice","credential":"abc"}}"#).unwrap(); + assert_eq!( + login, + Command::Login { + user: "alice".to_string(), + credential: "abc".to_string() + } + ); + + let play: Command = serde_json::from_str(r#"{"Play":{"game":"crash","bet":25}}"#).unwrap(); + assert_eq!( + play, + Command::Play { + game: "crash".to_string(), + bet: Some(25) + } + ); + + let bet: Command = + serde_json::from_str(r#"{"PlaceBetAt":{"bet":"outside","kind":"red"}}"#).unwrap(); + assert_eq!( + bet, + Command::PlaceBetAt(BetTarget::Outside { kind: Outside::Red }) + ); + + let split: Command = + serde_json::from_str(r#"{"PlaceBetAt":{"bet":"split","a":1,"b":2}}"#).unwrap(); + assert_eq!(split, Command::PlaceBetAt(BetTarget::Split { a: 1, b: 2 })); + + assert_eq!( + serde_json::from_str::(r#""Spin""#).unwrap(), + Command::Spin + ); + assert_eq!( + serde_json::from_str::(r#""ShowLobby""#).unwrap(), + Command::ShowLobby + ); + assert_eq!( + serde_json::from_str::(r#"{"SetBet":50}"#).unwrap(), + Command::SetBet(50) + ); + assert_eq!( + serde_json::from_str::(r#"{"MoveCursor":"Left"}"#).unwrap(), + Command::MoveCursor(Direction::Left) + ); +} + +#[test] +fn views_serialize_with_a_screen_tag() { + let lobby = View::Lobby(LobbyView { + connected: true, + user: Some("alice".to_string()), + account: Some("1".to_string()), + balance: Some(1000), + games: vec![("slots".to_string(), "Slots".to_string())], + login_error: None, + }); + let json = serde_json::to_value(&lobby).unwrap(); + assert_eq!(json["screen"], "lobby"); + assert_eq!(json["user"], "alice"); + + let slots = View::Slots(SlotsView { + bet: 10, + balance: Some(990), + reels: "CLOGD".chars().collect(), + win_cells: vec![0], + win_lines: vec![1], + win_type: "win".to_string(), + winnings: Some(20), + phase: SlotsPhase::Result, + error: None, + }); + let json = serde_json::to_value(&slots).unwrap(); + assert_eq!(json["screen"], "slots"); + assert_eq!(json["phase"], "result"); + assert_eq!(json["reels"][0], "C"); + + let roulette = View::Roulette(RouletteView { + bet_amount: 10, + balance: Some(1000), + chip: 25, + bets: Bets::default(), + cursor: Cursor::default(), + phase: RoulettePhase::Placing, + number: None, + color: None, + winnings: None, + error: None, + }); + let json = serde_json::to_value(&roulette).unwrap(); + assert_eq!(json["screen"], "roulette"); + assert_eq!(json["phase"], "placing"); + + let blackjack = View::Blackjack(BlackjackView { + status: "ready".to_string(), + balance: Some(1000), + bet: 10, + hand: None, + error: None, + }); + assert_eq!( + serde_json::to_value(&blackjack).unwrap()["screen"], + "blackjack" + ); + + let crash = View::Crash(CrashView { + status: "running".to_string(), + balance: Some(990), + bet: 10, + multiplier: 1.5, + potential: 15, + crash_point: None, + payout: 0, + started_at: 1_700_000_000_000, + commit: Some("ab".repeat(32)), + seed: None, + error: None, + }); + let json = serde_json::to_value(&crash).unwrap(); + assert_eq!(json["screen"], "crash"); + assert_eq!(json["commit"], "ab".repeat(32)); + + let history = View::History(HistoryView { records: vec![] }); + assert_eq!(serde_json::to_value(&history).unwrap()["screen"], "history"); +} -- cgit v1.2.3