diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18f900a..fbdac71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,3 +84,71 @@ jobs: run: | ./scripts/generate-bindings.sh git diff --exit-code -- crates/match-bindings + + match-e2e: + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - uses: actions/checkout@v4 + + - name: Install pinned Rust toolchain + run: rustup show + + - name: Cache Rust build outputs + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + workspaces: | + . -> target + modules/match -> modules/match/target + + - name: Install pinned SpacetimeDB CLI + run: | + curl -sSf https://install.spacetimedb.com | sh -s -- --yes + export PATH="$HOME/.local/bin:$PATH" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + spacetime version install 2.7.1 --use --yes + ./scripts/check-toolchain.sh + + - name: Start local SpacetimeDB + run: | + spacetime start \ + --listen-addr 127.0.0.1:3000 \ + --data-dir "$GITHUB_WORKSPACE/.spacetime-e2e-data" \ + --non-interactive \ + > "$RUNNER_TEMP/spacetime-e2e.log" 2>&1 & + echo "$!" > "$RUNNER_TEMP/spacetime-e2e.pid" + for attempt in {1..60}; do + if curl --fail --silent --show-error http://127.0.0.1:3000/v1/metrics > /dev/null; then + exit 0 + fi + sleep 1 + done + cat "$RUNNER_TEMP/spacetime-e2e.log" + exit 1 + + - name: Publish fresh match database + run: | + spacetime publish \ + --server local \ + --module-path modules/match \ + --delete-data=always \ + --yes \ + of-match-e2e-ci + + - name: Run live match smoke + run: | + cargo run -p match-e2e -- \ + --host http://127.0.0.1:3000 \ + --database of-match-e2e-ci + + - name: Stop local SpacetimeDB + if: always() + run: | + if [ -f "$RUNNER_TEMP/spacetime-e2e.pid" ]; then + kill "$(cat "$RUNNER_TEMP/spacetime-e2e.pid")" || true + fi + if [ -f "$RUNNER_TEMP/spacetime-e2e.log" ]; then + cat "$RUNNER_TEMP/spacetime-e2e.log" + fi 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/hud.rs b/crates/game-client/src/hud.rs index f31eb68..bb24152 100644 --- a/crates/game-client/src/hud.rs +++ b/crates/game-client/src/hud.rs @@ -1,4 +1,5 @@ use bevy::{ + app::AppExit, picking::hover::Hovered, prelude::*, ui::UiRect, @@ -10,7 +11,7 @@ use bevy::{ use crate::{ interaction::{InteractionState, OrderMode}, map_view::map_view_status_bundle, - model::{MatchView, ToastKind}, + model::{MatchPhase, MatchView, ToastKind}, network::{ClientIntent, NetworkSet}, }; @@ -60,6 +61,13 @@ struct MobilizationSlider; #[derive(Component)] struct MobilizationThumb; +#[derive(Component)] +struct ResultOverlay; +#[derive(Component)] +struct ResultTitle; +#[derive(Component)] +struct ResultDetails; + #[derive(Component)] struct CommandContextTitle; #[derive(Component)] @@ -130,6 +138,7 @@ impl Plugin for HudPlugin { update_hud.after(NetworkSet::Apply), update_command_bar.after(update_hud), update_slider_visuals.after(update_hud), + leave_match_after_victory, ), ); } @@ -155,6 +164,7 @@ fn spawn_hud(mut commands: Commands, view: Res) { spawn_bottom_bar(root, view.mobilization_target); spawn_toast(root); spawn_help(root); + spawn_result_overlay(root); }); } @@ -468,6 +478,51 @@ fn spawn_help(root: &mut ChildSpawnerCommands) { }); } +fn spawn_result_overlay(root: &mut ChildSpawnerCommands) { + root.spawn(( + Name::new("Match result overlay"), + ResultOverlay, + Node { + display: Display::None, + position_type: PositionType::Absolute, + left: percent(50), + top: percent(50), + width: px(480), + padding: UiRect::all(px(24)), + border: UiRect::all(px(2)), + flex_direction: FlexDirection::Column, + align_items: AlignItems::Center, + row_gap: px(12), + ..default() + }, + UiTransform::from_translation(Val2::percent(-50.0, -50.0)), + GlobalZIndex(60), + BackgroundColor(Color::srgba(0.025, 0.039, 0.049, 0.985)), + BorderColor::all(CYAN), + Pickable::IGNORE, + )) + .with_children(|overlay| { + overlay.spawn(( + ResultTitle, + Text::new("MATCH COMPLETE"), + TextFont::from_font_size(24.0), + TextColor(CYAN), + Pickable::IGNORE, + )); + overlay.spawn(( + ResultDetails, + Text::new(""), + TextFont::from_font_size(13.0), + TextColor(TEXT), + Node { + align_self: AlignSelf::Stretch, + ..default() + }, + Pickable::IGNORE, + )); + }); +} + fn section_title(value: &'static str) -> impl Bundle { ( Text::new(value), @@ -682,10 +737,13 @@ fn update_hud( Single<&mut Text, With>, Single<&mut Text, With>, Single<&mut Text, With>, + Single<&mut Text, With>, + Single<&mut Text, With>, )>, mut panels: ParamSet<( Single<(&mut Node, &mut BackgroundColor, &mut BorderColor), With>, Single<&mut Node, With>, + Single<(&mut Node, &mut BorderColor), With>, )>, slider: Single<(Entity, &SliderValue), With>, mut selection_totals: Local, @@ -902,6 +960,58 @@ fn update_hud( let mut toast_root = panels.p0(); toast_root.0.display = Display::None; } + + { + let mut overlay = panels.p2(); + if let MatchPhase::Victory(winner) = view.phase { + let local_won = winner == u32::from(view.local_player); + overlay.0.display = Display::Flex; + overlay.1.set_all(if local_won { CYAN } else { CORAL }); + + let mut title = texts.p6(); + set_text( + &mut title, + if local_won { + "VICTORY CONFIRMED".to_owned() + } else { + "MATCH COMPLETE".to_owned() + }, + ); + + let mut details = texts.p7(); + set_text( + &mut details, + format!( + "WINNER PLAYER {winner}\nLOCAL SEAT PLAYER {} // {}\nCONQUEST RESOLVED AT LOGICAL STEP {}\n\nESC // RETURN TO LOBBY DIRECTORY\nFROM THE DIRECTORY, SELECT LEAVE TO RETIRE THE LOBBY.", + view.local_player, + if local_won { "VICTORY" } else { "DEFEAT" }, + view.logical_step, + ), + ); + } else { + overlay.0.display = Display::None; + } + } +} + +fn leave_match_after_victory( + keyboard: Res>, + view: Res, + mut app_exit: MessageWriter, +) { + if !matches!(view.phase, MatchPhase::Victory(_)) || !keyboard.just_pressed(KeyCode::Escape) { + return; + } + + #[cfg(target_arch = "wasm32")] + { + if let Some(window) = web_sys::window() { + let _ = window.location().set_href("/"); + } + } + + #[cfg(not(target_arch = "wasm32"))] + app_exit.write(AppExit::Success); } fn update_slider_visuals( 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