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 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 107 insertions(+), 44 deletions(-) (limited to 'tests/client_core.rs') 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")); +} -- cgit v1.2.3