From 8037c7558a16fc4b359524657e1381b7a14ed8ce Mon Sep 17 00:00:00 2001 From: Diego Carlino Date: Fri, 7 Aug 2026 20:05:32 +0200 Subject: [PATCH 1/7] Qualify browser gates with observe, reconnect soak, and size budgets. Add Wasm download measurement, reconnect soak harness, F4/__ofPerf observability, and deploy-time bundle enforcement so browser release evidence can be recorded against documented budgets. --- .github/workflows/deploy.yml | 3 + Cargo.lock | 3 + README.md | 5 +- crates/game-client/Cargo.toml | 1 + crates/game-client/src/config.rs | 9 + crates/game-client/src/main.rs | 5 + crates/game-client/src/network.rs | 33 ++- crates/game-client/src/observe.rs | 307 +++++++++++++++++++++++ crates/game-client/src/online.rs | 140 ++++++++++- crates/game-client/src/performance.rs | 62 ++++- docs/README.md | 6 + docs/browser-gates.md | 218 ++++++++++++++++ docs/future-ideas.md | 2 +- docs/implementation.md | 14 +- docs/observability.md | 106 ++++++++ docs/performance.md | 1 + docs/technical-architecture.md | 6 +- modules/lobby/Cargo.lock | 1 + modules/lobby/Cargo.toml | 1 + modules/lobby/src/lib.rs | 36 ++- modules/match/Cargo.lock | 1 + modules/match/Cargo.toml | 1 + modules/match/src/lib.rs | 30 ++- modules/match/src/rules.rs | 21 +- modules/match/src/simulation.rs | 8 + scripts/measure-web-bundle.sh | 162 ++++++++++++ scripts/run-reconnect-soak.sh | 94 +++++++ tools/match-e2e/Cargo.toml | 2 + tools/match-e2e/src/main.rs | 341 ++++++++++++++++++++++---- 29 files changed, 1551 insertions(+), 68 deletions(-) create mode 100644 crates/game-client/src/observe.rs create mode 100644 docs/browser-gates.md create mode 100644 docs/observability.md create mode 100755 scripts/measure-web-bundle.sh create mode 100755 scripts/run-reconnect-soak.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1a24144..5b44c48 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -54,6 +54,9 @@ jobs: - name: Build production deployment run: ./scripts/build-vercel-production.sh + - name: Enforce web bundle size gate + run: ./scripts/measure-web-bundle.sh --dist target/web --enforce + - name: Publish authoritative module env: SPACETIMEDB_TOKEN: ${{ secrets.SPACETIMEDB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 791f933..c407c9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2989,6 +2989,7 @@ dependencies = [ "js-sys", "match-bindings", "spacetimedb-sdk", + "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "worldgen", @@ -3972,6 +3973,8 @@ dependencies = [ "clap", "hex-core", "match-bindings", + "serde", + "serde_json", "spacetimedb-sdk", ] diff --git a/README.md b/README.md index 52c9355..128f996 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ Browser identity tokens are scoped by host, database, and profile in `localStorage`. Production deployments must use HTTPS/WSS to protect those credentials; WebGPU itself requires a secure context (localhost is allowed for local development). Run `trunk build` from `crates/game-client` to -produce a deployable bundle in `target/web`. +produce a deployable bundle in `target/web`. Measure and enforce the download +budget with `./scripts/measure-web-bundle.sh --enforce` (see +[Browser release gates](docs/browser-gates.md)). ## Production deployment @@ -263,6 +265,7 @@ relevant keys; `?` opens the field manual. | `V` | Cycle map views | | `?` | Toggle the field manual | | `F3` | Toggle the performance overlay | +| `F4` | Toggle the observe event ring (`OF_OBSERVE=1` / `?observe=1` for console) | A cluster is the full connected set of owned passable cells. Empty owned cells can connect troop-bearing areas; blocked terrain and impassable elevation edges diff --git a/crates/game-client/Cargo.toml b/crates/game-client/Cargo.toml index 6ab1d1c..16da2a0 100644 --- a/crates/game-client/Cargo.toml +++ b/crates/game-client/Cargo.toml @@ -24,6 +24,7 @@ clap.workspace = true bevy = { workspace = true, features = ["webgpu"] } js-sys = "=0.3.103" spacetimedb-sdk = { workspace = true, features = ["browser"] } +wasm-bindgen = "=0.2.126" wasm-bindgen-futures = "=0.4.76" web-sys = { version = "=0.3.103", features = [ "Location", diff --git a/crates/game-client/src/config.rs b/crates/game-client/src/config.rs index 767e04b..8c82ae2 100644 --- a/crates/game-client/src/config.rs +++ b/crates/game-client/src/config.rs @@ -28,6 +28,7 @@ const DEFAULT_DATABASE: &str = match option_env!("OF_WEB_DATABASE") { about = "Native hex RTS client", disable_version_flag = true )] +#[allow(clippy::struct_excessive_bools)] struct ClientArgs { /// Use the local deterministic fixture instead of `SpacetimeDB`. #[arg(long)] @@ -88,6 +89,10 @@ struct ClientArgs { /// Skip the lobby UI and join immediately (env: `OF_AUTO_JOIN`). #[arg(long)] auto_join: bool, + + /// Emit structured `[of.observe]` events to the log/console (env: `OF_OBSERVE`). + #[arg(long)] + observe: bool, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -109,6 +114,8 @@ pub struct ClientConfig { pub profile: String, /// Automation/dev path: connect and call `join_match` after bootstrap. pub auto_join: bool, + /// Emit structured observe events to stderr / browser console. + pub observe: bool, } impl ClientConfig { @@ -167,6 +174,7 @@ impl ClientConfig { preferred_player, profile, auto_join, + observe: args.observe || env_flag("OF_OBSERVE"), } } @@ -200,6 +208,7 @@ impl ClientConfig { preferred_player, profile, auto_join, + observe: browser_flag("observe"), } } diff --git a/crates/game-client/src/main.rs b/crates/game-client/src/main.rs index 36db9a2..ec1fd7f 100644 --- a/crates/game-client/src/main.rs +++ b/crates/game-client/src/main.rs @@ -19,6 +19,7 @@ mod lobby; mod map_view; mod model; mod network; +mod observe; mod online; mod overlays; mod performance; @@ -35,6 +36,7 @@ use lobby::LobbyPlugin; use map_view::MapViewPlugin; use model::{MatchView, update_transient_state}; use network::{NetworkBoundaryPlugin, NetworkSet, OfflineTransportPlugin, apply_server_updates}; +use observe::ObservePlugin; use online::{OnlineSyncSet, OnlineTransportPlugin}; use overlays::OverlayPlugin; use performance::PerformanceOverlayPlugin; @@ -101,6 +103,9 @@ fn main() { LobbyPlugin, HudPlugin, PerformanceOverlayPlugin, + ObservePlugin { + console_enabled: config.observe, + }, )) .insert_resource(ClearColor(Color::srgb(0.018, 0.025, 0.031))) .insert_resource(GlobalAmbientLight { diff --git a/crates/game-client/src/network.rs b/crates/game-client/src/network.rs index 2f9af21..2fc10af 100644 --- a/crates/game-client/src/network.rs +++ b/crates/game-client/src/network.rs @@ -20,6 +20,7 @@ use crate::model::{ ActiveFlow, ActiveFront, MatchPhase, MatchView, OrderSelectionProjectionError, ProjectedOrderSelection, ToastKind, }; +use crate::observe::{ObserveLevel, ObserveState, keys as observe_keys}; #[derive(Message, Clone, Debug)] pub enum ClientIntent { @@ -194,12 +195,17 @@ pub fn resolve_offline_intents( } } -pub fn apply_server_updates(mut updates: MessageReader, mut view: ResMut) { +pub fn apply_server_updates( + mut updates: MessageReader, + mut view: ResMut, + mut observe: ResMut, +) { let mut offline_ownership_changed = false; for update in updates.read() { match update { ServerUpdate::SubmissionStarted { .. } => {} ServerUpdate::Accepted { + command_id, summary, patches, flow, @@ -219,12 +225,28 @@ pub fn apply_server_updates(mut updates: MessageReader, mut view: if let Some(front) = front { view.active_fronts.push(front.clone()); } + observe.emit( + ObserveLevel::Info, + observe_keys::CMD_ACCEPT, + format!( + "command_id={} summary={summary}", + command_id.map_or_else(|| "-".to_owned(), |id| id.to_string()) + ), + ); view.push_log(summary); view.show_toast("Command accepted", ToastKind::Success); } ServerUpdate::MobilizationChanged { command_id, target } => { view.mobilization_target = *target; let command = command_id.map_or_else(String::new, |id| format!(" · command #{id}")); + observe.emit( + ObserveLevel::Info, + observe_keys::CMD_ACCEPT, + format!( + "command_id={} kind=set_mobilization_target target={target:.2}", + command_id.map_or_else(|| "-".to_owned(), |id| id.to_string()) + ), + ); view.push_log(format!( "Mobilization target set to {:.0}%{command}", target * 100.0 @@ -232,6 +254,7 @@ pub fn apply_server_updates(mut updates: MessageReader, mut view: view.show_toast("Future recruitment target updated", ToastKind::Success); } ServerUpdate::Rejected { + command_id, reason, relevant_cell, .. @@ -239,6 +262,14 @@ pub fn apply_server_updates(mut updates: MessageReader, mut view: let marker = relevant_cell.map_or_else(String::new, |cell| { format!(" · marked {},{}", cell.q, cell.r) }); + observe.emit( + ObserveLevel::Warn, + observe_keys::CMD_REJECT, + format!( + "command_id={} reason={reason}", + command_id.map_or_else(|| "-".to_owned(), |id| id.to_string()) + ), + ); view.push_log(format!("Rejected: {reason}{marker}")); view.show_toast(reason, ToastKind::Rejection); } diff --git a/crates/game-client/src/observe.rs b/crates/game-client/src/observe.rs new file mode 100644 index 0000000..19ea0b0 --- /dev/null +++ b/crates/game-client/src/observe.rs @@ -0,0 +1,307 @@ +//! Lightweight structured observability for the Bevy client. +//! +//! Events use stable `category.action` keys so browser `DevTools`, native stderr, +//! and the F4 overlay stay greppable. Console emission is gated by +//! `OF_OBSERVE=1` / `--observe` (native) or `?observe=1` (wasm); the in-memory +//! ring always records recent events so F4 works without a restart. + +use std::collections::VecDeque; +use std::fmt::Write as _; + +use bevy::{ + diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin}, + prelude::*, + ui::UiRect, +}; + +const RING_CAPACITY: usize = 64; +const FRAME_SPIKE_MS: f64 = 33.0; +/// Prefer Bevy `Time` over `std::time::Instant` — Instant panics on wasm. +const FRAME_SPIKE_COOLDOWN_SECS: f64 = 2.0; +const PANEL: Color = Color::srgba(0.025, 0.038, 0.047, 0.94); +const LINE: Color = Color::srgba(0.42, 0.58, 0.65, 0.48); +const TEXT: Color = Color::srgb(0.72, 0.91, 0.93); + +/// Stable event keys. Prefer extending this list over inventing ad-hoc strings. +pub mod keys { + pub const NET_CONNECT_BEGIN: &str = "net.connect_begin"; + pub const NET_CONNECTED: &str = "net.connected"; + pub const NET_BOOTSTRAP: &str = "net.bootstrap"; + pub const NET_TACTICAL: &str = "net.tactical"; + pub const NET_DISCONNECT: &str = "net.disconnect"; + pub const NET_RECONNECT: &str = "net.reconnect"; + pub const NET_CONNECT_FAIL: &str = "net.connect_fail"; + pub const NET_JOIN_FAIL: &str = "net.join_fail"; + pub const LOBBY_ACTION: &str = "lobby.action"; + pub const CMD_SUBMIT: &str = "cmd.submit"; + pub const CMD_ACCEPT: &str = "cmd.accept"; + pub const CMD_REJECT: &str = "cmd.reject"; + pub const CMD_FAIL: &str = "cmd.fail"; + pub const SYNC_APPLY: &str = "sync.apply"; + pub const PERF_FRAME_SPIKE: &str = "perf.frame_spike"; + pub const TOKEN_WARN: &str = "auth.token_warn"; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub enum ObserveLevel { + Debug, + Info, + Warn, + Error, +} + +impl ObserveLevel { + const fn as_str(self) -> &'static str { + match self { + Self::Debug => "DEBUG", + Self::Info => "INFO", + Self::Warn => "WARN", + Self::Error => "ERROR", + } + } +} + +#[derive(Clone, Debug)] +struct ObserveRecord { + seq: u64, + level: ObserveLevel, + key: &'static str, + detail: String, +} + +#[derive(Resource)] +pub struct ObserveState { + /// When true, events also go to Bevy's log (stderr / browser console). + pub console_enabled: bool, + pub overlay_visible: bool, + next_seq: u64, + events: VecDeque, + last_frame_spike_at_secs: Option, + refresh_overlay: bool, +} + +impl ObserveState { + pub fn new(console_enabled: bool) -> Self { + Self { + console_enabled, + overlay_visible: false, + next_seq: 1, + events: VecDeque::with_capacity(RING_CAPACITY), + last_frame_spike_at_secs: None, + refresh_overlay: false, + } + } + + pub fn emit(&mut self, level: ObserveLevel, key: &'static str, detail: impl Into) { + let detail = detail.into(); + let seq = self.next_seq; + self.next_seq = self.next_seq.saturating_add(1); + if self.console_enabled { + emit_console(level, key, &detail); + } + self.events.push_back(ObserveRecord { + seq, + level, + key, + detail, + }); + while self.events.len() > RING_CAPACITY { + self.events.pop_front(); + } + self.refresh_overlay = true; + } + + fn note_frame_spike(&mut self, now_secs: f64, frame_ms: f64, fps: Option) { + if self.last_frame_spike_at_secs.is_some_and(|previous| { + now_secs - previous < FRAME_SPIKE_COOLDOWN_SECS + }) { + return; + } + self.last_frame_spike_at_secs = Some(now_secs); + let fps_part = fps.map_or_else(|| "fps=--".to_owned(), |value| format!("fps={value:.1}")); + self.emit( + ObserveLevel::Warn, + keys::PERF_FRAME_SPIKE, + format!("frame_ms={frame_ms:.2} {fps_part} threshold_ms={FRAME_SPIKE_MS}"), + ); + } +} + +fn emit_console(level: ObserveLevel, key: &str, detail: &str) { + let message = format!("[of.observe] {} {key} {detail}", level.as_str()); + match level { + ObserveLevel::Debug => bevy::log::debug!(target: "of.observe", "{message}"), + ObserveLevel::Info => bevy::log::info!(target: "of.observe", "{message}"), + ObserveLevel::Warn => bevy::log::warn!(target: "of.observe", "{message}"), + ObserveLevel::Error => bevy::log::error!(target: "of.observe", "{message}"), + } +} + +#[derive(Component)] +struct ObservePanel; + +#[derive(Component)] +struct ObserveText; + +pub struct ObservePlugin { + pub console_enabled: bool, +} + +impl Plugin for ObservePlugin { + fn build(&self, app: &mut App) { + app.insert_resource(ObserveState::new(self.console_enabled)) + .add_systems(Startup, spawn_observe_overlay) + .add_systems( + Update, + ( + toggle_observe_overlay, + detect_frame_spikes, + update_observe_overlay, + ) + .chain(), + ); + } +} + +fn spawn_observe_overlay(mut commands: Commands) { + commands + .spawn(( + Name::new("Observe overlay"), + ObservePanel, + Node { + display: Display::None, + position_type: PositionType::Absolute, + left: px(72), + top: px(286), + width: px(620), + max_height: px(320), + padding: UiRect::all(px(10)), + border: UiRect::all(px(1)), + flex_direction: FlexDirection::Column, + row_gap: px(4), + overflow: Overflow::clip_y(), + ..default() + }, + BackgroundColor(PANEL), + BorderColor::all(LINE), + GlobalZIndex(31), + Pickable::IGNORE, + )) + .with_child(( + ObserveText, + Text::new("OBSERVE // F4\nWaiting for events…"), + TextFont::from_font_size(11.0), + TextColor(TEXT), + Pickable::IGNORE, + )); +} + +fn toggle_observe_overlay( + keyboard: Res>, + mut state: ResMut, + panel: Single<&mut Node, With>, +) { + if !keyboard.just_pressed(KeyCode::F4) { + return; + } + let mut panel = panel.into_inner(); + state.overlay_visible = !state.overlay_visible; + panel.display = if state.overlay_visible { + Display::Flex + } else { + Display::None + }; + state.refresh_overlay = state.overlay_visible; +} + +fn detect_frame_spikes( + time: Res