From 19ff2c96e503f779688fe076815e0bccdb8e80cb Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 4 Aug 2026 16:40:42 -0500 Subject: [PATCH 1/6] feat(e2e-ui): ship blob board pure as WASM from blob-core Extract pure simulate_move into blob-core (no distributed host), export it via blob-wasm, and replace the TypeScript board twin with a thin loader that registers the same pure id for auto-optimism. make wasm / ui-install builds pkg; CI installs wasm-pack + wasm32. --- .github/workflows/integration-e2e-ui.yaml | 8 + tests/e2e-ui/Cargo.toml | 2 + tests/e2e-ui/Makefile | 18 +- tests/e2e-ui/crates/blob-core/Cargo.toml | 11 ++ .../e2e-ui/crates/blob-core/src/direction.rs | 31 +++ tests/e2e-ui/crates/blob-core/src/lib.rs | 10 + tests/e2e-ui/crates/blob-core/src/simulate.rs | 146 ++++++++++++++ tests/e2e-ui/crates/blob-core/src/tile.rs | 8 + tests/e2e-ui/crates/blob-domain/Cargo.toml | 1 + tests/e2e-ui/crates/blob-domain/src/lib.rs | 4 +- .../blob-domain/src/models/blob_game.rs | 96 +--------- .../blob-domain/src/models/direction.rs | 32 +--- .../crates/blob-domain/src/models/tile.rs | 9 +- tests/e2e-ui/crates/blob-wasm/Cargo.toml | 19 ++ tests/e2e-ui/crates/blob-wasm/src/lib.rs | 33 ++++ .../e2e-ui/crates/service/src/modules/blob.rs | 2 +- tests/e2e-ui/ui/src/lib/blob/simulate-move.ts | 179 ++++++++---------- .../src/routes/blob/[[gameId]]/+page.svelte | 10 +- tests/e2e-ui/ui/vite.config.ts | 5 + 19 files changed, 390 insertions(+), 234 deletions(-) create mode 100644 tests/e2e-ui/crates/blob-core/Cargo.toml create mode 100644 tests/e2e-ui/crates/blob-core/src/direction.rs create mode 100644 tests/e2e-ui/crates/blob-core/src/lib.rs create mode 100644 tests/e2e-ui/crates/blob-core/src/simulate.rs create mode 100644 tests/e2e-ui/crates/blob-core/src/tile.rs create mode 100644 tests/e2e-ui/crates/blob-wasm/Cargo.toml create mode 100644 tests/e2e-ui/crates/blob-wasm/src/lib.rs diff --git a/.github/workflows/integration-e2e-ui.yaml b/.github/workflows/integration-e2e-ui.yaml index ec4b0ffc..19cd5470 100644 --- a/.github/workflows/integration-e2e-ui.yaml +++ b/.github/workflows/integration-e2e-ui.yaml @@ -28,12 +28,16 @@ jobs: - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: toolchain: stable + targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 with: workspaces: tests/e2e-ui -> target shared-key: e2e-ui-offline + - name: Install wasm-pack + run: cargo install wasm-pack --locked || cargo install wasm-pack + - uses: actions/setup-node@v4 with: node-version: "22" @@ -62,12 +66,16 @@ jobs: - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: toolchain: stable + targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 with: workspaces: tests/e2e-ui -> target shared-key: e2e-ui-browser + - name: Install wasm-pack + run: cargo install wasm-pack --locked || cargo install wasm-pack + - uses: actions/setup-node@v4 with: node-version: "22" diff --git a/tests/e2e-ui/Cargo.toml b/tests/e2e-ui/Cargo.toml index cd0e2a16..62879695 100644 --- a/tests/e2e-ui/Cargo.toml +++ b/tests/e2e-ui/Cargo.toml @@ -7,7 +7,9 @@ resolver = "2" members = [ "crates/todo-domain", "crates/chat-domain", + "crates/blob-core", "crates/blob-domain", + "crates/blob-wasm", "crates/readmodels", "crates/projections", "crates/service", diff --git a/tests/e2e-ui/Makefile b/tests/e2e-ui/Makefile index 4afb618c..2f622f86 100644 --- a/tests/e2e-ui/Makefile +++ b/tests/e2e-ui/Makefile @@ -6,7 +6,7 @@ # make test-browser # Playwright UI e2e (needs make up + make run) .PHONY: all up down run run-api stop test ci-offline test-domain test-suite \ - test-browser test-browser-install js-install js-build ui-install ui-build ui-check ui-test \ + test-browser test-browser-install js-install js-build wasm ui-install ui-build ui-check ui-test \ gen-client check-client contracts-check check clean help # Defaults only — do NOT `include e2e-ui.env` (shell-quoted dotenv breaks Make). @@ -118,7 +118,7 @@ ci-offline: ui-install echo "OK — generated drift + offline Rust/UI suites" test-domain: - cargo test -p todo-domain -p chat-domain $(CARGO_TEST_FLAGS) + cargo test -p todo-domain -p chat-domain -p blob-core -p blob-domain $(CARGO_TEST_FLAGS) test-suite: cargo test -p e2e-suite --test behavioral $(CARGO_TEST_FLAGS) @@ -140,7 +140,14 @@ js-install: js-build: js-install cd $(JS_DIR) && $(NPM) run build -ui-install: js-build +## Pure blob board rules → ui/src/lib/blob/pkg (wasm-pack; requires wasm32-unknown-unknown). +wasm: + @command -v wasm-pack >/dev/null || { echo "wasm-pack required: cargo install wasm-pack"; exit 1; } + @rustup target list --installed | grep -q wasm32-unknown-unknown || rustup target add wasm32-unknown-unknown + @out="$(CURDIR)/ui/src/lib/blob/pkg"; \ + wasm-pack build crates/blob-wasm --target web --out-dir "$$out" --out-name blob_wasm + +ui-install: js-build wasm cd ui && $(NPM) install ui-build: ui-install @@ -171,13 +178,13 @@ contracts-check: check: check-client cargo check --workspace - cargo test -p todo-domain -p chat-domain --no-run + cargo test -p todo-domain -p chat-domain -p blob-core -p blob-domain --no-run cargo test -p e2e-suite --test behavioral --no-run clean: stop cargo clean rm -f .make-runner.log e2e-ui.db - rm -rf ui/node_modules ui/build ui/.svelte-kit + rm -rf ui/node_modules ui/build ui/.svelte-kit ui/src/lib/blob/pkg help: @echo "e2e-ui" @@ -185,6 +192,7 @@ help: @echo " make run API + UI (source e2e-ui.env when present)" @echo " make test offline suite + UI structural" @echo " make ci-offline CI drift + offline suites with safe pipeline overlap" + @echo " make wasm blob-core pure → ui/src/lib/blob/pkg (wasm-pack)" @echo " make gen-client typed Service → generated user/admin clients" @echo " make check-client verify generated artifacts byte-for-byte" @echo " make down docker compose down" diff --git a/tests/e2e-ui/crates/blob-core/Cargo.toml b/tests/e2e-ui/crates/blob-core/Cargo.toml new file mode 100644 index 00000000..af41577f --- /dev/null +++ b/tests/e2e-ui/crates/blob-core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "blob-core" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true +description = "Pure blob board rules (WASM-eligible; no distributed host)" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/tests/e2e-ui/crates/blob-core/src/direction.rs b/tests/e2e-ui/crates/blob-core/src/direction.rs new file mode 100644 index 00000000..c2f35d8c --- /dev/null +++ b/tests/e2e-ui/crates/blob-core/src/direction.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Direction { + Up, + Down, + Left, + Right, +} + +impl Direction { + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "up" => Some(Self::Up), + "down" => Some(Self::Down), + "left" => Some(Self::Left), + "right" => Some(Self::Right), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Up => "up", + Self::Down => "down", + Self::Left => "left", + Self::Right => "right", + } + } +} diff --git a/tests/e2e-ui/crates/blob-core/src/lib.rs b/tests/e2e-ui/crates/blob-core/src/lib.rs new file mode 100644 index 00000000..9d2c5603 --- /dev/null +++ b/tests/e2e-ui/crates/blob-core/src/lib.rs @@ -0,0 +1,10 @@ +//! Pure blob board rules shared by the domain aggregate and client WASM. +//! +//! No I/O, no `distributed`, no ownership — only map + score + direction. + +mod direction; +mod simulate; +pub mod tile; + +pub use direction::Direction; +pub use simulate::{simulate_move, MovePreview, SimulateError}; diff --git a/tests/e2e-ui/crates/blob-core/src/simulate.rs b/tests/e2e-ui/crates/blob-core/src/simulate.rs new file mode 100644 index 00000000..382a34fc --- /dev/null +++ b/tests/e2e-ui/crates/blob-core/src/simulate.rs @@ -0,0 +1,146 @@ +//! Pure post-move board snapshot. + +use crate::tile; +use crate::Direction; + +/// Pure post-move board snapshot (no ownership / aggregate checks). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MovePreview { + pub map: Vec>, + pub score: i64, + pub player_dead: bool, + pub level_complete: bool, +} + +impl MovePreview { + pub fn status(&self) -> String { + if self.player_dead { + "dead".into() + } else if self.level_complete { + "level_complete".into() + } else { + "active".into() + } + } +} + +/// Failures from pure board simulation (fail-closed on the client). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SimulateError { + NoActiveLevel, + CannotMove(&'static str), +} + +/// Apply one direction to a map + score. +pub fn simulate_move( + map: &[Vec], + score: i64, + direction: Direction, +) -> Result { + if map.is_empty() || map[0].is_empty() { + return Err(SimulateError::NoActiveLevel); + } + let (r, c) = player_pos_in(map)?; + let (nr, nc) = match direction { + Direction::Up => { + if r == 0 { + return Err(SimulateError::CannotMove("row already 0")); + } + (r - 1, c) + } + Direction::Down => { + if r + 1 >= map.len() { + return Err(SimulateError::CannotMove("already at bottom edge")); + } + (r + 1, c) + } + Direction::Left => { + if c == 0 { + return Err(SimulateError::CannotMove("column already 0")); + } + (r, c - 1) + } + Direction::Right => { + if c + 1 >= map[r].len() { + return Err(SimulateError::CannotMove("already at right edge")); + } + (r, c + 1) + } + }; + + let mut next_map = map.to_vec(); + let mut score = score; + let mut player_dead = false; + let mut level_complete = false; + + next_map[r][c] = tile::VISITED; + match next_map[nr][nc] { + tile::HOLE => next_map[nr][nc] = tile::DEAD_BY_HOLE, + tile::VISITED => next_map[nr][nc] = tile::DEAD_BY_SUICIDE, + tile::UNVISITED | tile::PLAYER => { + score += 1; + next_map[nr][nc] = tile::PLAYER; + } + _ => next_map[nr][nc] = tile::DEAD_BY_SUICIDE, + } + for row in &next_map { + if row.contains(&tile::DEAD_BY_HOLE) || row.contains(&tile::DEAD_BY_SUICIDE) { + player_dead = true; + level_complete = false; + break; + } + } + if !player_dead { + let any_u = next_map.iter().any(|row| row.contains(&tile::UNVISITED)); + level_complete = !any_u; + } + + Ok(MovePreview { + map: next_map, + score, + player_dead, + level_complete, + }) +} + +fn player_pos_in(map: &[Vec]) -> Result<(usize, usize), SimulateError> { + for (r, row) in map.iter().enumerate() { + for (c, &t) in row.iter().enumerate() { + if t == tile::PLAYER { + return Ok((r, c)); + } + } + } + Err(SimulateError::NoActiveLevel) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tile::*; + + fn tiny() -> Vec> { + vec![ + vec![PLAYER, UNVISITED, UNVISITED], + vec![UNVISITED, UNVISITED, UNVISITED], + vec![UNVISITED, UNVISITED, UNVISITED], + ] + } + + #[test] + fn move_right_increments_score() { + let preview = simulate_move(&tiny(), 0, Direction::Right).unwrap(); + assert_eq!(preview.score, 1); + assert!(!preview.player_dead); + assert_eq!(preview.map[0][0], VISITED); + assert_eq!(preview.map[0][1], PLAYER); + } + + #[test] + fn edge_fails_closed() { + assert!(matches!( + simulate_move(&tiny(), 0, Direction::Up), + Err(SimulateError::CannotMove(_)) + )); + } +} diff --git a/tests/e2e-ui/crates/blob-core/src/tile.rs b/tests/e2e-ui/crates/blob-core/src/tile.rs new file mode 100644 index 00000000..9b2cb17f --- /dev/null +++ b/tests/e2e-ui/crates/blob-core/src/tile.rs @@ -0,0 +1,8 @@ +//! Canonical tile values (client board + domain parity). + +pub const HOLE: u8 = 0; +pub const UNVISITED: u8 = 1; +pub const VISITED: u8 = 2; +pub const DEAD_BY_SUICIDE: u8 = 3; +pub const DEAD_BY_HOLE: u8 = 4; +pub const PLAYER: u8 = 9; diff --git a/tests/e2e-ui/crates/blob-domain/Cargo.toml b/tests/e2e-ui/crates/blob-domain/Cargo.toml index c6b22f07..44779cd3 100644 --- a/tests/e2e-ui/crates/blob-domain/Cargo.toml +++ b/tests/e2e-ui/crates/blob-domain/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true publish.workspace = true [dependencies] +blob-core = { path = "../blob-core" } distributed = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/tests/e2e-ui/crates/blob-domain/src/lib.rs b/tests/e2e-ui/crates/blob-domain/src/lib.rs index ecaa3728..982b5f64 100644 --- a/tests/e2e-ui/crates/blob-domain/src/lib.rs +++ b/tests/e2e-ui/crates/blob-domain/src/lib.rs @@ -1,7 +1,7 @@ //! BlobGame aggregate — grid trail game (remake of ig-blob-game-model-service). //! -//! Tile ints match JS `constants.ts` (player=9, hole=0, unvisited=1, visited=2, -//! dead_by_suicide=3, dead_by_hole=4). Read models update only from emitted facts. +//! Pure board rules live in [`blob_core`] (WASM-eligible). Tile ints match the +//! client board helpers (player=9, hole=0, unvisited=1, visited=2, …). pub mod levels; pub mod models; diff --git a/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs b/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs index df0748f3..e197f328 100644 --- a/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs +++ b/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs @@ -63,97 +63,17 @@ fn status_of(player_dead: bool, level_complete: bool) -> String { } } -/// Pure post-move board snapshot (no ownership / aggregate checks). -/// -/// Shared by the aggregate and client-side optimistic preview (TypeScript port -/// in e2e-ui must stay byte-identical for tile rules). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct MovePreview { - pub map: Vec>, - pub score: i64, - pub player_dead: bool, - pub level_complete: bool, -} +// Pure post-move board snapshot — defined in `blob_core`, re-exported here. +pub use blob_core::{simulate_move, MovePreview}; -impl MovePreview { - pub fn status(&self) -> String { - status_of(self.player_dead, self.level_complete) +fn map_simulate_err(err: blob_core::SimulateError) -> BlobError { + match err { + blob_core::SimulateError::NoActiveLevel => BlobError::NoActiveLevel, + blob_core::SimulateError::CannotMove(msg) => BlobError::CannotMove(msg.into()), } } -/// Apply one direction to a map + score. Pure — used by [`BlobGame::move_dir`] -/// and mirrored in the e2e-ui optimistic board sim. -pub fn simulate_move( - map: &[Vec], - score: i64, - direction: Direction, -) -> Result { - if map.is_empty() || map[0].is_empty() { - return Err(BlobError::NoActiveLevel); - } - let (r, c) = player_pos_in(map)?; - let (nr, nc) = match direction { - Direction::Up => { - if r == 0 { - return Err(BlobError::CannotMove("row already 0".into())); - } - (r - 1, c) - } - Direction::Down => { - if r + 1 >= map.len() { - return Err(BlobError::CannotMove("already at bottom edge".into())); - } - (r + 1, c) - } - Direction::Left => { - if c == 0 { - return Err(BlobError::CannotMove("column already 0".into())); - } - (r, c - 1) - } - Direction::Right => { - if c + 1 >= map[r].len() { - return Err(BlobError::CannotMove("already at right edge".into())); - } - (r, c + 1) - } - }; - - let mut next_map = map.to_vec(); - let mut score = score; - let mut player_dead = false; - let mut level_complete = false; - - next_map[r][c] = tile::VISITED; - match next_map[nr][nc] { - tile::HOLE => next_map[nr][nc] = tile::DEAD_BY_HOLE, - tile::VISITED => next_map[nr][nc] = tile::DEAD_BY_SUICIDE, - tile::UNVISITED | tile::PLAYER => { - score += 1; - next_map[nr][nc] = tile::PLAYER; - } - _ => next_map[nr][nc] = tile::DEAD_BY_SUICIDE, - } - for row in &next_map { - if row.contains(&tile::DEAD_BY_HOLE) || row.contains(&tile::DEAD_BY_SUICIDE) { - player_dead = true; - level_complete = false; - break; - } - } - if !player_dead { - let any_u = next_map.iter().any(|row| row.contains(&tile::UNVISITED)); - level_complete = !any_u; - } - - Ok(MovePreview { - map: next_map, - score, - player_dead, - level_complete, - }) -} - +#[cfg(test)] fn player_pos_in(map: &[Vec]) -> Result<(usize, usize), BlobError> { for (r, row) in map.iter().enumerate() { for (c, &t) in row.iter().enumerate() { @@ -343,7 +263,7 @@ impl BlobGame { if self.current_level == 0 || self.map.is_empty() { return Err(BlobError::NoActiveLevel); } - let preview = simulate_move(&self.map, self.score, direction)?; + let preview = simulate_move(&self.map, self.score, direction).map_err(map_simulate_err)?; self.record_moved( preview.score, preview.player_dead, diff --git a/tests/e2e-ui/crates/blob-domain/src/models/direction.rs b/tests/e2e-ui/crates/blob-domain/src/models/direction.rs index c2f35d8c..59b863fc 100644 --- a/tests/e2e-ui/crates/blob-domain/src/models/direction.rs +++ b/tests/e2e-ui/crates/blob-domain/src/models/direction.rs @@ -1,31 +1,3 @@ -use serde::{Deserialize, Serialize}; +//! Re-export pure direction from [`blob_core`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Direction { - Up, - Down, - Left, - Right, -} - -impl Direction { - pub fn parse(s: &str) -> Option { - match s.trim().to_ascii_lowercase().as_str() { - "up" => Some(Self::Up), - "down" => Some(Self::Down), - "left" => Some(Self::Left), - "right" => Some(Self::Right), - _ => None, - } - } - - pub fn as_str(self) -> &'static str { - match self { - Self::Up => "up", - Self::Down => "down", - Self::Left => "left", - Self::Right => "right", - } - } -} +pub use blob_core::Direction; diff --git a/tests/e2e-ui/crates/blob-domain/src/models/tile.rs b/tests/e2e-ui/crates/blob-domain/src/models/tile.rs index 5534c1cb..9841f12e 100644 --- a/tests/e2e-ui/crates/blob-domain/src/models/tile.rs +++ b/tests/e2e-ui/crates/blob-domain/src/models/tile.rs @@ -1,8 +1,3 @@ -//! Canonical tile values (JS parity). +//! Re-export pure tile constants from [`blob_core`]. -pub const HOLE: u8 = 0; -pub const UNVISITED: u8 = 1; -pub const VISITED: u8 = 2; -pub const DEAD_BY_SUICIDE: u8 = 3; -pub const DEAD_BY_HOLE: u8 = 4; -pub const PLAYER: u8 = 9; +pub use blob_core::tile::*; diff --git a/tests/e2e-ui/crates/blob-wasm/Cargo.toml b/tests/e2e-ui/crates/blob-wasm/Cargo.toml new file mode 100644 index 00000000..6b644cce --- /dev/null +++ b/tests/e2e-ui/crates/blob-wasm/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "blob-wasm" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true +description = "WASM exports of blob-core pure board rules for client auto-optimism" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +blob-core = { path = "../blob-core" } +serde = { workspace = true } +serde_json = { workspace = true } +wasm-bindgen = "0.2" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/tests/e2e-ui/crates/blob-wasm/src/lib.rs b/tests/e2e-ui/crates/blob-wasm/src/lib.rs new file mode 100644 index 00000000..c672aada --- /dev/null +++ b/tests/e2e-ui/crates/blob-wasm/src/lib.rs @@ -0,0 +1,33 @@ +//! Client WASM surface for blob pure reduces. +//! +//! JS calls [`blob_simulate_move`] with `map_json`, score, and direction; gets +//! JSON fields for the optimistic patch (or null / undefined on fail-closed). + +use blob_core::{simulate_move, Direction}; +use wasm_bindgen::prelude::*; + +/// Apply one move for known-row optimism. +/// +/// Returns a JSON object: +/// `{ map_json, score, player_dead, current_level_completed, status }` +/// or `undefined` when the move is impossible / input is invalid. +#[wasm_bindgen(js_name = blobSimulateMove)] +pub fn blob_simulate_move(map_json: &str, score: f64, direction: &str) -> Option { + if !score.is_finite() { + return None; + } + // JS numbers are f64; board scores are small integers. + let score = score as i64; + let direction = Direction::parse(direction)?; + let map: Vec> = serde_json::from_str(map_json).ok()?; + let preview = simulate_move(&map, score, direction).ok()?; + let map_json = serde_json::to_string(&preview.map).ok()?; + let body = serde_json::json!({ + "map_json": map_json, + "score": preview.score, + "player_dead": preview.player_dead, + "current_level_completed": preview.level_complete, + "status": preview.status(), + }); + Some(body.to_string()) +} diff --git a/tests/e2e-ui/crates/service/src/modules/blob.rs b/tests/e2e-ui/crates/service/src/modules/blob.rs index b7318e54..376461c4 100644 --- a/tests/e2e-ui/crates/service/src/modules/blob.rs +++ b/tests/e2e-ui/crates/service/src/modules/blob.rs @@ -64,7 +64,7 @@ where >(blob_move::COMMAND) .field_name("blob_games_move") .roles(["user", "admin"].into_iter()) - // Domain pure: blob_domain::simulate_move — client twin at $lib/blob/simulate-move. + // Domain pure: blob_core::simulate_move — client via blob-wasm ($lib/blob/simulate-move). .preview_reduce_known_record( CommandProjectionPureReduce::new( "blob.simulate_move", diff --git a/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts b/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts index 46a3cc10..757ec910 100644 --- a/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts +++ b/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts @@ -1,22 +1,13 @@ /** - * Pure post-move board snapshot — TypeScript twin of - * `blob_domain::simulate_move`. Must stay byte-identical for tile rules so - * auto-optimism paints the same board the Atomic response will seal. + * Client pure for `blob.simulate_move` — thin host over `blob-core` WASM. * - * Registered as pure function `blob.simulate_move` on the command runtime. + * Board rules live once in Rust (`blob_core::simulate_move`). This module only + * loads the wasm package, adapts replica record/args, and fails closed when the + * module is not ready or the move is impossible. + * + * Build: `make wasm` (or `make ui-install`) → `./pkg` from wasm-pack. */ -const HOLE = 0; -const UNVISITED = 1; -const VISITED = 2; -const DEAD_BY_SUICIDE = 3; -const DEAD_BY_HOLE = 4; -const PLAYER = 9; - -export type BlobMoveArgs = Readonly<{ - direction: string; -}>; - export type BlobMoveResult = Readonly<{ map_json: string; score: number; @@ -25,27 +16,70 @@ export type BlobMoveResult = Readonly<{ status: string; }>; +type WasmApi = { + default: (input?: unknown) => Promise; + blobSimulateMove: ( + map_json: string, + score: number, + direction: string + ) => string | undefined; +}; + +let api: WasmApi | null = null; +let initPromise: Promise | null = null; + +function isBrowser(): boolean { + return typeof window !== 'undefined'; +} + /** - * Apply one direction to a known BlobGames row. - * Returns null when the move is impossible (edge/no map) so optimism fails closed. + * Load and instantiate blob-core WASM. Safe to call multiple times. + * No-op on the server (SSR) — pure reduce fails closed until the client inits. + */ +export function ensureBlobWasm(): Promise { + if (!isBrowser()) { + return Promise.resolve(); + } + if (api) { + return Promise.resolve(); + } + if (initPromise) { + return initPromise; + } + initPromise = (async () => { + const mod = (await import('./pkg/blob_wasm.js')) as WasmApi; + await mod.default(); + api = mod; + })().catch((error) => { + initPromise = null; + api = null; + throw error; + }); + return initPromise; +} + +// Warm the module early on the client so the first move can pure-reduce. +if (isBrowser()) { + void ensureBlobWasm().catch(() => { + // Fail closed later in simulateMove; do not break module load. + }); +} + +/** + * Apply one direction to a known BlobGames row (WASM pure). + * Returns null when not ready, invalid, or the move is impossible. */ export function simulateMove( record: Readonly>, args: Readonly> ): BlobMoveResult | null { + if (!api) { + return null; + } const direction = args.direction; if (typeof direction !== 'string') return null; const mapJson = record.map_json; if (typeof mapJson !== 'string') return null; - let map: number[][]; - try { - map = JSON.parse(mapJson) as number[][]; - } catch { - return null; - } - if (!Array.isArray(map) || map.length === 0 || !Array.isArray(map[0]) || map[0]!.length === 0) { - return null; - } const scoreRaw = record.score; const score = typeof scoreRaw === 'number' @@ -57,84 +91,31 @@ export function simulateMove( : NaN; if (!Number.isFinite(score)) return null; - const pos = playerPos(map); - if (pos === null) return null; - const [r, c] = pos; - const next = step(r, c, direction, map); - if (next === null) return null; - const [nr, nc] = next; - - const nextMap = map.map((row) => row.slice()); - let nextScore = score; - let playerDead = false; - let levelComplete = false; - - nextMap[r]![c] = VISITED; - const target = nextMap[nr]![nc]!; - if (target === HOLE) { - nextMap[nr]![nc] = DEAD_BY_HOLE; - } else if (target === VISITED) { - nextMap[nr]![nc] = DEAD_BY_SUICIDE; - } else if (target === UNVISITED || target === PLAYER) { - nextScore += 1; - nextMap[nr]![nc] = PLAYER; - } else { - nextMap[nr]![nc] = DEAD_BY_SUICIDE; - } - - for (const row of nextMap) { - if (row.includes(DEAD_BY_HOLE) || row.includes(DEAD_BY_SUICIDE)) { - playerDead = true; - levelComplete = false; - break; - } - } - if (!playerDead) { - levelComplete = !nextMap.some((row) => row.includes(UNVISITED)); - } - - return Object.freeze({ - map_json: JSON.stringify(nextMap), - score: nextScore, - player_dead: playerDead, - current_level_completed: levelComplete, - status: playerDead ? 'dead' : levelComplete ? 'level_complete' : 'active' - }); -} - -function playerPos(map: number[][]): [number, number] | null { - for (let r = 0; r < map.length; r += 1) { - const row = map[r]!; - for (let c = 0; c < row.length; c += 1) { - if (row[c] === PLAYER) return [r, c]; - } + let json: string | undefined; + try { + json = api.blobSimulateMove(mapJson, score, direction); + } catch { + return null; } - return null; -} - -function step( - r: number, - c: number, - direction: string, - map: number[][] -): [number, number] | null { - switch (direction) { - case 'up': - return r === 0 ? null : [r - 1, c]; - case 'down': - return r + 1 >= map.length ? null : [r + 1, c]; - case 'left': - return c === 0 ? null : [r, c - 1]; - case 'right': { - const row = map[r]!; - return c + 1 >= row.length ? null : [r, c + 1]; - } - default: + if (json === undefined || json === '') return null; + try { + const parsed = JSON.parse(json) as BlobMoveResult; + if ( + typeof parsed.map_json !== 'string' || + typeof parsed.score !== 'number' || + typeof parsed.player_dead !== 'boolean' || + typeof parsed.current_level_completed !== 'boolean' || + typeof parsed.status !== 'string' + ) { return null; + } + return Object.freeze(parsed); + } catch { + return null; } } -/** Pure registry entry for the command runtime. */ +/** Pure registry entry for the command runtime (and generated pures.ts). */ export const BLOB_PURE_FUNCTIONS = Object.freeze({ 'blob.simulate_move': simulateMove }); diff --git a/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte b/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte index 7414557d..ff30117d 100644 --- a/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte @@ -4,14 +4,16 @@ * * - URL (`/blob` | `/blob/{gameId}`) selects which game is active. * - Board and history derive from `BlobGames.use()`. - * - Commands are Atomic: thin input (`game_id` + `direction`); board updates - * when the authoritative row seals on the response. + * - Commands are Atomic: thin input (`game_id` + `direction`). Known-row pure + * optimism runs `blob.simulate_move` via blob-core WASM; Atomic seal is + * still server authority. */ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; import { BlobGames, useCommands } from '$distributed'; import { parseBoard, TILE, type Direction } from '$lib/blob/board'; + import { ensureBlobWasm } from '$lib/blob/simulate-move'; import { Button } from '$lib/components/shared/ui'; import { AppPage, InlineAlert, PageHeader } from '$lib/components/product'; import { HowItsBuilt } from '$lib/components/walkthrough'; @@ -189,6 +191,10 @@ onMount(() => { hydrated = true; + // Ensure blob-core WASM is ready before pure-reduce optimism on move. + void ensureBlobWasm().catch(() => { + /* fail-closed pure until reload */ + }); const testWindow = window as Window & { __distributedBlobRefetch?: () => Promise; }; diff --git a/tests/e2e-ui/ui/vite.config.ts b/tests/e2e-ui/ui/vite.config.ts index 9c4010a4..865e084f 100644 --- a/tests/e2e-ui/ui/vite.config.ts +++ b/tests/e2e-ui/ui/vite.config.ts @@ -12,9 +12,14 @@ const api = process.env.E2E_API_ORIGIN || process.env.E2E_BASE_URL || 'http://12 export default defineConfig({ plugins: [distributedSvelteKit(distributedViteOptions), sveltekit()], css: { devSourcemap: true }, + // blob-core pure package (wasm-pack --target web) + assetsInclude: ['**/*.wasm'], server: { port: 5180, // GraphQL-only public API (commands are mutations, not POST /todo.*). proxy: distributedGraphqlProxy(api) + }, + optimizeDeps: { + exclude: ['$lib/blob/pkg/blob_wasm.js'] } }); From 9a828a932d4e55679ddb994b28a738c68cfa9ce4 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 4 Aug 2026 18:17:58 -0500 Subject: [PATCH 2/6] refactor(e2e-ui): single blob-domain crate with core + wasm features Collapse blob-core and blob-wasm into blob-domain: pure rules live in src/core/, WASM exports in src/wasm.rs behind --features wasm, aggregate host stays default --features domain. make wasm builds that one package. --- tests/e2e-ui/Cargo.toml | 2 -- tests/e2e-ui/Makefile | 11 ++++---- tests/e2e-ui/crates/blob-core/Cargo.toml | 11 -------- tests/e2e-ui/crates/blob-core/src/lib.rs | 10 ------- tests/e2e-ui/crates/blob-domain/Cargo.toml | 19 ++++++++++--- .../src => blob-domain/src/core}/direction.rs | 0 .../e2e-ui/crates/blob-domain/src/core/mod.rs | 10 +++++++ .../src => blob-domain/src/core}/simulate.rs | 6 ++--- .../src => blob-domain/src/core}/tile.rs | 0 tests/e2e-ui/crates/blob-domain/src/lib.rs | 27 ++++++++++++++----- .../blob-domain/src/models/blob_game.rs | 10 +++---- .../blob-domain/src/models/direction.rs | 4 +-- .../crates/blob-domain/src/models/tile.rs | 4 +-- .../src/lib.rs => blob-domain/src/wasm.rs} | 6 ++--- tests/e2e-ui/crates/blob-wasm/Cargo.toml | 19 ------------- .../e2e-ui/crates/service/src/modules/blob.rs | 2 +- tests/e2e-ui/ui/src/lib/blob/simulate-move.ts | 13 ++++----- .../src/routes/blob/[[gameId]]/+page.svelte | 2 +- tests/e2e-ui/ui/vite.config.ts | 2 +- 19 files changed, 76 insertions(+), 82 deletions(-) delete mode 100644 tests/e2e-ui/crates/blob-core/Cargo.toml delete mode 100644 tests/e2e-ui/crates/blob-core/src/lib.rs rename tests/e2e-ui/crates/{blob-core/src => blob-domain/src/core}/direction.rs (100%) create mode 100644 tests/e2e-ui/crates/blob-domain/src/core/mod.rs rename tests/e2e-ui/crates/{blob-core/src => blob-domain/src/core}/simulate.rs (98%) rename tests/e2e-ui/crates/{blob-core/src => blob-domain/src/core}/tile.rs (100%) rename tests/e2e-ui/crates/{blob-wasm/src/lib.rs => blob-domain/src/wasm.rs} (85%) delete mode 100644 tests/e2e-ui/crates/blob-wasm/Cargo.toml diff --git a/tests/e2e-ui/Cargo.toml b/tests/e2e-ui/Cargo.toml index 62879695..cd0e2a16 100644 --- a/tests/e2e-ui/Cargo.toml +++ b/tests/e2e-ui/Cargo.toml @@ -7,9 +7,7 @@ resolver = "2" members = [ "crates/todo-domain", "crates/chat-domain", - "crates/blob-core", "crates/blob-domain", - "crates/blob-wasm", "crates/readmodels", "crates/projections", "crates/service", diff --git a/tests/e2e-ui/Makefile b/tests/e2e-ui/Makefile index 2f622f86..0427d580 100644 --- a/tests/e2e-ui/Makefile +++ b/tests/e2e-ui/Makefile @@ -118,7 +118,7 @@ ci-offline: ui-install echo "OK — generated drift + offline Rust/UI suites" test-domain: - cargo test -p todo-domain -p chat-domain -p blob-core -p blob-domain $(CARGO_TEST_FLAGS) + cargo test -p todo-domain -p chat-domain -p blob-domain $(CARGO_TEST_FLAGS) test-suite: cargo test -p e2e-suite --test behavioral $(CARGO_TEST_FLAGS) @@ -140,12 +140,13 @@ js-install: js-build: js-install cd $(JS_DIR) && $(NPM) run build -## Pure blob board rules → ui/src/lib/blob/pkg (wasm-pack; requires wasm32-unknown-unknown). +## blob-domain pure core → ui/src/lib/blob/pkg (wasm-pack; requires wasm32-unknown-unknown). wasm: @command -v wasm-pack >/dev/null || { echo "wasm-pack required: cargo install wasm-pack"; exit 1; } @rustup target list --installed | grep -q wasm32-unknown-unknown || rustup target add wasm32-unknown-unknown @out="$(CURDIR)/ui/src/lib/blob/pkg"; \ - wasm-pack build crates/blob-wasm --target web --out-dir "$$out" --out-name blob_wasm + wasm-pack build crates/blob-domain --target web --out-dir "$$out" --out-name blob_wasm \ + -- --no-default-features --features wasm ui-install: js-build wasm cd ui && $(NPM) install @@ -178,7 +179,7 @@ contracts-check: check: check-client cargo check --workspace - cargo test -p todo-domain -p chat-domain -p blob-core -p blob-domain --no-run + cargo test -p todo-domain -p chat-domain -p blob-domain --no-run cargo test -p e2e-suite --test behavioral --no-run clean: stop @@ -192,7 +193,7 @@ help: @echo " make run API + UI (source e2e-ui.env when present)" @echo " make test offline suite + UI structural" @echo " make ci-offline CI drift + offline suites with safe pipeline overlap" - @echo " make wasm blob-core pure → ui/src/lib/blob/pkg (wasm-pack)" + @echo " make wasm blob-domain core → ui/src/lib/blob/pkg (wasm-pack)" @echo " make gen-client typed Service → generated user/admin clients" @echo " make check-client verify generated artifacts byte-for-byte" @echo " make down docker compose down" diff --git a/tests/e2e-ui/crates/blob-core/Cargo.toml b/tests/e2e-ui/crates/blob-core/Cargo.toml deleted file mode 100644 index af41577f..00000000 --- a/tests/e2e-ui/crates/blob-core/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "blob-core" -version.workspace = true -edition.workspace = true -license.workspace = true -publish.workspace = true -description = "Pure blob board rules (WASM-eligible; no distributed host)" - -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } diff --git a/tests/e2e-ui/crates/blob-core/src/lib.rs b/tests/e2e-ui/crates/blob-core/src/lib.rs deleted file mode 100644 index 9d2c5603..00000000 --- a/tests/e2e-ui/crates/blob-core/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Pure blob board rules shared by the domain aggregate and client WASM. -//! -//! No I/O, no `distributed`, no ownership — only map + score + direction. - -mod direction; -mod simulate; -pub mod tile; - -pub use direction::Direction; -pub use simulate::{simulate_move, MovePreview, SimulateError}; diff --git a/tests/e2e-ui/crates/blob-domain/Cargo.toml b/tests/e2e-ui/crates/blob-domain/Cargo.toml index 44779cd3..d7ef74f5 100644 --- a/tests/e2e-ui/crates/blob-domain/Cargo.toml +++ b/tests/e2e-ui/crates/blob-domain/Cargo.toml @@ -4,14 +4,25 @@ version.workspace = true edition.workspace = true license.workspace = true publish.workspace = true +description = "Blob game domain: pure board core, aggregate host, optional WASM export" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["domain"] +# Aggregate + levels + distributed host (server / tests). +domain = ["dep:distributed", "dep:thiserror", "dep:rand"] +# Client pure export (wasm-pack / wasm32). +wasm = ["dep:wasm-bindgen"] [dependencies] -blob-core = { path = "../blob-core" } -distributed = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -thiserror = { workspace = true } -rand = { workspace = true } +distributed = { workspace = true, optional = true } +thiserror = { workspace = true, optional = true } +rand = { workspace = true, optional = true } +wasm-bindgen = { version = "0.2", optional = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/tests/e2e-ui/crates/blob-core/src/direction.rs b/tests/e2e-ui/crates/blob-domain/src/core/direction.rs similarity index 100% rename from tests/e2e-ui/crates/blob-core/src/direction.rs rename to tests/e2e-ui/crates/blob-domain/src/core/direction.rs diff --git a/tests/e2e-ui/crates/blob-domain/src/core/mod.rs b/tests/e2e-ui/crates/blob-domain/src/core/mod.rs new file mode 100644 index 00000000..a2b14e63 --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/core/mod.rs @@ -0,0 +1,10 @@ +//! Pure board rules — no I/O, no `distributed`, WASM-eligible. +//! +//! Shared by the aggregate host (`models`) and the client WASM surface (`wasm`). + +mod direction; +mod simulate; +pub mod tile; + +pub use direction::Direction; +pub use simulate::{simulate_move, MovePreview, SimulateError}; diff --git a/tests/e2e-ui/crates/blob-core/src/simulate.rs b/tests/e2e-ui/crates/blob-domain/src/core/simulate.rs similarity index 98% rename from tests/e2e-ui/crates/blob-core/src/simulate.rs rename to tests/e2e-ui/crates/blob-domain/src/core/simulate.rs index 382a34fc..240e8be9 100644 --- a/tests/e2e-ui/crates/blob-core/src/simulate.rs +++ b/tests/e2e-ui/crates/blob-domain/src/core/simulate.rs @@ -1,7 +1,7 @@ //! Pure post-move board snapshot. -use crate::tile; -use crate::Direction; +use super::tile; +use super::Direction; /// Pure post-move board snapshot (no ownership / aggregate checks). #[derive(Clone, Debug, PartialEq, Eq)] @@ -117,7 +117,7 @@ fn player_pos_in(map: &[Vec]) -> Result<(usize, usize), SimulateError> { #[cfg(test)] mod tests { use super::*; - use crate::tile::*; + use super::tile::*; fn tiny() -> Vec> { vec![ diff --git a/tests/e2e-ui/crates/blob-core/src/tile.rs b/tests/e2e-ui/crates/blob-domain/src/core/tile.rs similarity index 100% rename from tests/e2e-ui/crates/blob-core/src/tile.rs rename to tests/e2e-ui/crates/blob-domain/src/core/tile.rs diff --git a/tests/e2e-ui/crates/blob-domain/src/lib.rs b/tests/e2e-ui/crates/blob-domain/src/lib.rs index 982b5f64..f71a4b4a 100644 --- a/tests/e2e-ui/crates/blob-domain/src/lib.rs +++ b/tests/e2e-ui/crates/blob-domain/src/lib.rs @@ -1,15 +1,28 @@ -//! BlobGame aggregate — grid trail game (remake of ig-blob-game-model-service). +//! Blob game domain — one crate, three faces: //! -//! Pure board rules live in [`blob_core`] (WASM-eligible). Tile ints match the -//! client board helpers (player=9, hole=0, unvisited=1, visited=2, …). +//! - [`core`] — pure board rules (always available; WASM-eligible) +//! - [`models`] / [`levels`] — aggregate host (`feature = "domain"`, default) +//! - [`wasm`] — `blobSimulateMove` for the client (`feature = "wasm"`) +//! +//! Tile ints match the client board helpers (player=9, hole=0, unvisited=1, …). + +pub mod core; +#[cfg(feature = "domain")] pub mod levels; +#[cfg(feature = "domain")] pub mod models; +#[cfg(feature = "wasm")] +pub mod wasm; + +pub use core::{simulate_move, tile, Direction, MovePreview, SimulateError}; + +#[cfg(feature = "domain")] pub use levels::{demo_map, generate_level, generate_level_with, is_hamiltonian_passable}; -pub use models::tile; +#[cfg(feature = "domain")] pub use models::{ - domain_commands, simulate_move, test_map_no_holes, test_map_with_hole, BlobError, BlobGame, - BlobGameState, BlobInitializedDomainEvent, BlobLevelStartedDomainEvent, BlobMovedDomainEvent, - BlobStartedDomainEvent, Direction, MovePreview, + domain_commands, test_map_no_holes, test_map_with_hole, BlobError, BlobGame, BlobGameState, + BlobInitializedDomainEvent, BlobLevelStartedDomainEvent, BlobMovedDomainEvent, + BlobStartedDomainEvent, }; diff --git a/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs b/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs index e197f328..e15f1ae4 100644 --- a/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs +++ b/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs @@ -63,13 +63,13 @@ fn status_of(player_dead: bool, level_complete: bool) -> String { } } -// Pure post-move board snapshot — defined in `blob_core`, re-exported here. -pub use blob_core::{simulate_move, MovePreview}; +// Pure post-move board snapshot — defined in `crate::core`, re-exported here. +pub use crate::core::{simulate_move, MovePreview}; -fn map_simulate_err(err: blob_core::SimulateError) -> BlobError { +fn map_simulate_err(err: crate::core::SimulateError) -> BlobError { match err { - blob_core::SimulateError::NoActiveLevel => BlobError::NoActiveLevel, - blob_core::SimulateError::CannotMove(msg) => BlobError::CannotMove(msg.into()), + crate::core::SimulateError::NoActiveLevel => BlobError::NoActiveLevel, + crate::core::SimulateError::CannotMove(msg) => BlobError::CannotMove(msg.into()), } } diff --git a/tests/e2e-ui/crates/blob-domain/src/models/direction.rs b/tests/e2e-ui/crates/blob-domain/src/models/direction.rs index 59b863fc..85323ef2 100644 --- a/tests/e2e-ui/crates/blob-domain/src/models/direction.rs +++ b/tests/e2e-ui/crates/blob-domain/src/models/direction.rs @@ -1,3 +1,3 @@ -//! Re-export pure direction from [`blob_core`]. +//! Re-export pure direction from [`crate::core`]. -pub use blob_core::Direction; +pub use crate::core::Direction; diff --git a/tests/e2e-ui/crates/blob-domain/src/models/tile.rs b/tests/e2e-ui/crates/blob-domain/src/models/tile.rs index 9841f12e..56a7ba46 100644 --- a/tests/e2e-ui/crates/blob-domain/src/models/tile.rs +++ b/tests/e2e-ui/crates/blob-domain/src/models/tile.rs @@ -1,3 +1,3 @@ -//! Re-export pure tile constants from [`blob_core`]. +//! Re-export pure tile constants from [`crate::core`]. -pub use blob_core::tile::*; +pub use crate::core::tile::*; diff --git a/tests/e2e-ui/crates/blob-wasm/src/lib.rs b/tests/e2e-ui/crates/blob-domain/src/wasm.rs similarity index 85% rename from tests/e2e-ui/crates/blob-wasm/src/lib.rs rename to tests/e2e-ui/crates/blob-domain/src/wasm.rs index c672aada..8bdd7885 100644 --- a/tests/e2e-ui/crates/blob-wasm/src/lib.rs +++ b/tests/e2e-ui/crates/blob-domain/src/wasm.rs @@ -1,9 +1,9 @@ -//! Client WASM surface for blob pure reduces. +//! Client WASM surface for pure board rules (`--features wasm`). //! //! JS calls [`blob_simulate_move`] with `map_json`, score, and direction; gets -//! JSON fields for the optimistic patch (or null / undefined on fail-closed). +//! JSON fields for the optimistic patch (or undefined on fail-closed). -use blob_core::{simulate_move, Direction}; +use crate::core::{simulate_move, Direction}; use wasm_bindgen::prelude::*; /// Apply one move for known-row optimism. diff --git a/tests/e2e-ui/crates/blob-wasm/Cargo.toml b/tests/e2e-ui/crates/blob-wasm/Cargo.toml deleted file mode 100644 index 6b644cce..00000000 --- a/tests/e2e-ui/crates/blob-wasm/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "blob-wasm" -version.workspace = true -edition.workspace = true -license.workspace = true -publish.workspace = true -description = "WASM exports of blob-core pure board rules for client auto-optimism" - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -blob-core = { path = "../blob-core" } -serde = { workspace = true } -serde_json = { workspace = true } -wasm-bindgen = "0.2" - -[package.metadata.wasm-pack.profile.release] -wasm-opt = false diff --git a/tests/e2e-ui/crates/service/src/modules/blob.rs b/tests/e2e-ui/crates/service/src/modules/blob.rs index 376461c4..50705b66 100644 --- a/tests/e2e-ui/crates/service/src/modules/blob.rs +++ b/tests/e2e-ui/crates/service/src/modules/blob.rs @@ -64,7 +64,7 @@ where >(blob_move::COMMAND) .field_name("blob_games_move") .roles(["user", "admin"].into_iter()) - // Domain pure: blob_core::simulate_move — client via blob-wasm ($lib/blob/simulate-move). + // Domain pure: blob_domain::core::simulate_move — client via WASM ($lib/blob/simulate-move). .preview_reduce_known_record( CommandProjectionPureReduce::new( "blob.simulate_move", diff --git a/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts b/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts index 757ec910..55cc3c23 100644 --- a/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts +++ b/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts @@ -1,11 +1,12 @@ /** - * Client pure for `blob.simulate_move` — thin host over `blob-core` WASM. + * Client pure for `blob.simulate_move` — thin host over `blob-domain` WASM. * - * Board rules live once in Rust (`blob_core::simulate_move`). This module only - * loads the wasm package, adapts replica record/args, and fails closed when the - * module is not ready or the move is impossible. + * Board rules live once in Rust (`blob_domain::core::simulate_move`). This + * module only loads the wasm package, adapts replica record/args, and fails + * closed when the module is not ready or the move is impossible. * - * Build: `make wasm` (or `make ui-install`) → `./pkg` from wasm-pack. + * Build: `make wasm` (or `make ui-install`) → `./pkg` from wasm-pack + * (`--features wasm --no-default-features` on blob-domain). */ export type BlobMoveResult = Readonly<{ @@ -33,7 +34,7 @@ function isBrowser(): boolean { } /** - * Load and instantiate blob-core WASM. Safe to call multiple times. + * Load and instantiate blob-domain pure WASM. Safe to call multiple times. * No-op on the server (SSR) — pure reduce fails closed until the client inits. */ export function ensureBlobWasm(): Promise { diff --git a/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte b/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte index ff30117d..9ad32344 100644 --- a/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte @@ -191,7 +191,7 @@ onMount(() => { hydrated = true; - // Ensure blob-core WASM is ready before pure-reduce optimism on move. + // Ensure blob-domain pure WASM is ready before pure-reduce optimism on move. void ensureBlobWasm().catch(() => { /* fail-closed pure until reload */ }); diff --git a/tests/e2e-ui/ui/vite.config.ts b/tests/e2e-ui/ui/vite.config.ts index 865e084f..c71fdbab 100644 --- a/tests/e2e-ui/ui/vite.config.ts +++ b/tests/e2e-ui/ui/vite.config.ts @@ -12,7 +12,7 @@ const api = process.env.E2E_API_ORIGIN || process.env.E2E_BASE_URL || 'http://12 export default defineConfig({ plugins: [distributedSvelteKit(distributedViteOptions), sveltekit()], css: { devSourcemap: true }, - // blob-core pure package (wasm-pack --target web) + // blob-domain pure package (wasm-pack --features wasm) assetsInclude: ['**/*.wasm'], server: { port: 5180, From 57562328c31d4459ba26da1c1ee31cb538f92593 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 4 Aug 2026 19:58:57 -0500 Subject: [PATCH 3/6] =?UTF-8?q?docs(e2e-ui):=20refresh=20How-it=E2=80=99s-?= =?UTF-8?q?built=20for=20pure/WASM=20and=20service=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update blob walkthrough for known-row pure reduce + blob-domain WASM, session guards/principal in command mounts and handlers, and add a Service module tab on every demo (MODULE_ID, routes, compose). --- .../components/walkthrough/HowItsBuilt.svelte | 2 +- tests/e2e-ui/ui/src/lib/walkthrough/demos.ts | 382 +++++++++++++----- 2 files changed, 285 insertions(+), 99 deletions(-) diff --git a/tests/e2e-ui/ui/src/lib/components/walkthrough/HowItsBuilt.svelte b/tests/e2e-ui/ui/src/lib/components/walkthrough/HowItsBuilt.svelte index 68a6d86d..b074d6f9 100644 --- a/tests/e2e-ui/ui/src/lib/components/walkthrough/HowItsBuilt.svelte +++ b/tests/e2e-ui/ui/src/lib/components/walkthrough/HowItsBuilt.svelte @@ -68,7 +68,7 @@

How it’s built

{demo.summary}