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/server_core.rs | 121 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 105 insertions(+), 16 deletions(-) (limited to 'tests/server_core.rs') 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)); +} -- cgit v1.2.3