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/distributed_cli/src/client_compiler/manifest/types.rs b/distributed_cli/src/client_compiler/manifest/types.rs index 2ca442ae..14e901a6 100644 --- a/distributed_cli/src/client_compiler/manifest/types.rs +++ b/distributed_cli/src/client_compiler/manifest/types.rs @@ -1083,8 +1083,14 @@ pub(crate) struct ManifestCommandProjection { #[serde(deny_unknown_fields)] pub(crate) struct ManifestCommandPureReduce { pub(crate) fn_name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] pub(crate) client_module: String, + #[serde(default, skip_serializing_if = "String::is_empty")] pub(crate) client_export: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub(crate) wasm_package: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub(crate) wasm_export: String, pub(crate) model: String, pub(crate) key: Vec, pub(crate) args: Vec, diff --git a/distributed_cli/src/client_compiler/render/commands.rs b/distributed_cli/src/client_compiler/render/commands.rs index e0af0beb..d927b911 100644 --- a/distributed_cli/src/client_compiler/render/commands.rs +++ b/distributed_cli/src/client_compiler/render/commands.rs @@ -22,7 +22,7 @@ pub(super) fn render_commands(manifest: &ClientManifest) -> Result Result` named export. + ClientModule { module: String, export: String }, + /// wasm-pack package under `$lib`; host is generated in pures.ts. + WasmPackage { package: String, export: String }, +} + /// Collect unique pure functions from command projection extensions. fn pure_function_inventory( manifest: &ClientManifest, -) -> Vec<(String, String, String)> { +) -> Result, ClientCompileError> { let mut seen = BTreeSet::new(); let mut out = Vec::new(); for command in &manifest.commands { @@ -114,41 +123,90 @@ fn pure_function_inventory( continue; }; for reduce in &projection.pure_reduces { - if seen.insert(reduce.fn_name.clone()) { - out.push(( - reduce.fn_name.clone(), - reduce.client_module.clone(), - reduce.client_export.clone(), - )); + if !seen.insert(reduce.fn_name.clone()) { + continue; } + let hand = !reduce.client_module.is_empty() || !reduce.client_export.is_empty(); + let wasm = !reduce.wasm_package.is_empty() || !reduce.wasm_export.is_empty(); + let delivery = if hand && !wasm { + PureDelivery::ClientModule { + module: reduce.client_module.clone(), + export: reduce.client_export.clone(), + } + } else if wasm && !hand { + PureDelivery::WasmPackage { + package: reduce.wasm_package.clone(), + export: reduce.wasm_export.clone(), + } + } else { + return Err(ClientCompileError::manifest( + "client.projection_pure_reduce", + format!( + "pure `{}` must declare either client_module+client_export or wasm_package+wasm_export", + reduce.fn_name + ), + )); + }; + out.push((reduce.fn_name.clone(), delivery)); } } out.sort_by(|a, b| a.0.cmp(&b.0)); - out + Ok(out) } -/// Generate `pures.ts` mapping pure fn ids to client module exports. +/// Generate `pures.ts` mapping pure fn ids to hosts / hand imports. pub(super) fn render_pures(manifest: &ClientManifest) -> Result, ClientCompileError> { - let inventory = pure_function_inventory(manifest); + let inventory = pure_function_inventory(manifest)?; if inventory.is_empty() { return Ok(None); } - let mut imports = Vec::new(); + let mut sections = vec![ + "/** GENERATED by distributed client. Pure functions for projection.pureReduces. */" + .to_string(), + ]; + let needs_wasm = inventory + .iter() + .any(|(_, d)| matches!(d, PureDelivery::WasmPackage { .. })); + if needs_wasm { + sections.push( + "import { createWasmJsonPure } from '@hops-ops/distributed/replica';".into(), + ); + } let mut entries = Vec::new(); - for (index, (fn_name, module, export)) in inventory.iter().enumerate() { - let alias = format!("pure_{index}"); - // From generated// to $lib/ - let rel = format!("../../{module}.js"); - imports.push(format!( - "import {{ {export} as {alias} }} from '{rel}';" - )); - entries.push(format!(" {}: {alias}", quoted_property(fn_name))); + let mut ready_calls = Vec::new(); + for (index, (fn_name, delivery)) in inventory.iter().enumerate() { + match delivery { + PureDelivery::ClientModule { module, export } => { + let alias = format!("pure_{index}"); + // From generated// to $lib/ + let rel = format!("../../{module}.js"); + sections.push(format!("import {{ {export} as {alias} }} from '{rel}';")); + entries.push(format!(" {}: {alias}", quoted_property(fn_name))); + } + PureDelivery::WasmPackage { package, export } => { + let host = format!("pureHost_{index}"); + // From generated// to $lib/.js (wasm-pack entry) + let rel = format!("../../{package}.js"); + sections.push(format!( + "const {host} = createWasmJsonPure({{\n load: () => import('{rel}'),\n exportName: {export_lit}\n}});", + export_lit = serde_json::to_string(export).unwrap_or_else(|_| "\"\"".into()), + )); + entries.push(format!(" {}: {host}.pure", quoted_property(fn_name))); + ready_calls.push(format!(" await {host}.ensureReady();")); + } + } } - Ok(Some(format!( - "/** GENERATED by distributed client. Pure functions for projection.pureReduces. */\n\n{}\n\nexport const PURE_FUNCTIONS = {{\n{}\n}} as const;\n", - imports.join("\n"), + sections.push(format!( + "export const PURE_FUNCTIONS = {{\n{}\n}} as const;", entries.join(",\n") - ))) + )); + if !ready_calls.is_empty() { + sections.push(format!( + "/** Instantiate WASM pure hosts (no-op when none / already ready). */\nexport async function ensurePureFunctionsReady(): Promise {{\n{}\n}}", + ready_calls.join("\n") + )); + } + Ok(Some(format!("{}\n", sections.join("\n\n")))) } fn validate_command_namespaces(commands: &[ManifestCommand]) -> Result<(), ClientCompileError> { diff --git a/distributed_cli/src/client_compiler/render/project.rs b/distributed_cli/src/client_compiler/render/project.rs index 81f4b1c9..c527152c 100644 --- a/distributed_cli/src/client_compiler/render/project.rs +++ b/distributed_cli/src/client_compiler/render/project.rs @@ -46,7 +46,9 @@ pub(crate) fn render_project( path: "commands.ts".into(), contents: render_commands(manifest)?, }); - if let Some(pures) = super::commands::render_pures(manifest)? { + let pures = super::commands::render_pures(manifest)?; + let has_pures = pures.is_some(); + if let Some(pures) = pures { files.push(GeneratedClientFile { path: "pures.ts".into(), contents: pures, @@ -66,7 +68,7 @@ pub(crate) fn render_project( }); files.push(GeneratedClientFile { path: "index.ts".into(), - contents: render_index(&operations), + contents: render_index(&operations, has_pures), }); files.push(GeneratedClientFile { path: "manifest.json".into(), @@ -257,13 +259,16 @@ fn render_routes( )) } -fn render_index(operations: &[CompiledOperation]) -> String { +fn render_index(operations: &[CompiledOperation], has_pures: bool) -> String { let mut lines = vec![ "/** GENERATED public entrypoint. */".to_string(), "export * from './commands.js';".into(), "export * from './protocol.js';".into(), "export * from './routes.js';".into(), ]; + if has_pures { + lines.push("export * from './pures.js';".into()); + } for operation in operations { let module = operation .module_path diff --git a/js/src/replica/index.ts b/js/src/replica/index.ts index bb2282c4..4637414e 100644 --- a/js/src/replica/index.ts +++ b/js/src/replica/index.ts @@ -113,10 +113,14 @@ export type { ReplicaPreparedEffectKey, ReplicaReceiptVerification } from './commands.js'; +export { createWasmJsonPure } from './projection-delta/index.js'; export type { + CreateWasmJsonPureOptions, PreparedCommandProjection, PreparedProjectionOperation, - ReplicaCommandProjection + ReplicaCommandProjection, + WasmJsonModule, + WasmJsonPureHost } from './projection-delta/index.js'; export { compareReplicaOrder, diff --git a/js/src/replica/projection-delta/index.ts b/js/src/replica/projection-delta/index.ts index a5b77fc8..d2280138 100644 --- a/js/src/replica/projection-delta/index.ts +++ b/js/src/replica/projection-delta/index.ts @@ -12,6 +12,12 @@ export { operationsFromProjectionDelta, prepareCommandProjection } from './resolve.js'; +export { createWasmJsonPure } from './wasm-pure.js'; +export type { + CreateWasmJsonPureOptions, + WasmJsonModule, + WasmJsonPureHost +} from './wasm-pure.js'; export type { AppliedProjectionDelta, CommandProjectionMetadata, diff --git a/js/src/replica/projection-delta/wasm-pure.ts b/js/src/replica/projection-delta/wasm-pure.ts new file mode 100644 index 00000000..b472c7c1 --- /dev/null +++ b/js/src/replica/projection-delta/wasm-pure.ts @@ -0,0 +1,109 @@ +/** + * Framework host for pure reduces backed by a wasm-bindgen module. + * + * Domain pures validate record/args and return assign fields (or null). + * This helper only: lazy-load, JSON bridge, fail-closed on any host error. + */ + +import type { ReplicaValue } from '../types.js'; +import type { ReplicaPureFunction } from './types.js'; + +/** Minimal wasm-bindgen module shape used by {@link createWasmJsonPure}. */ +export type WasmJsonModule = { + default: (input?: unknown) => Promise; + [exportName: string]: unknown; +}; + +export type CreateWasmJsonPureOptions = Readonly<{ + /** Dynamic import of the wasm-pack module (e.g. `() => import('./pkg/x.js')`). */ + load: () => Promise; + /** + * Named export that accepts `(recordJson: string, argsJson: string)` and + * returns a JSON object string of assign fields, or undefined/null to skip. + */ + exportName: string; + /** When true (default in browsers), start loading immediately. */ + warm?: boolean; +}>; + +export type WasmJsonPureHost = Readonly<{ + /** Resolve when the module is instantiated (no-op on non-browser). */ + ensureReady: () => Promise; + /** Sync pure for `pureFunctions` / pureReduces. */ + pure: ReplicaPureFunction; +}>; + +function isBrowser(): boolean { + return typeof globalThis !== 'undefined' && 'window' in globalThis; +} + +/** + * Build a {@link ReplicaPureFunction} that JSON-roundtrips through a WASM export. + * + * All field validation belongs in the WASM/domain pure. The host never inspects + * individual record/arg keys — missing module or throw → null (fail closed). + */ +export function createWasmJsonPure( + options: CreateWasmJsonPureOptions +): WasmJsonPureHost { + let module: WasmJsonModule | null = null; + let initPromise: Promise | null = null; + + const ensureReady = (): Promise => { + if (!isBrowser()) { + return Promise.resolve(); + } + if (module) { + return Promise.resolve(); + } + if (initPromise) { + return initPromise; + } + initPromise = options + .load() + .then(async (loaded) => { + await loaded.default(); + module = loaded; + }) + .catch((error) => { + initPromise = null; + module = null; + throw error; + }); + return initPromise; + }; + + if (options.warm !== false && isBrowser()) { + void ensureReady().catch(() => { + /* fail closed on first pure call */ + }); + } + + const pure: ReplicaPureFunction = (record, args) => { + if (!module) { + return null; + } + const fn = module[options.exportName]; + if (typeof fn !== 'function') { + return null; + } + try { + const out = (fn as (r: string, a: string) => string | undefined | null)( + JSON.stringify(record), + JSON.stringify(args) + ); + if (out === undefined || out === null || out === '') { + return null; + } + const parsed = JSON.parse(out) as unknown; + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + return Object.freeze(parsed as Readonly>); + } catch { + return null; + } + }; + + return Object.freeze({ ensureReady, pure }); +} diff --git a/src/graphql/client_manifest/projections.rs b/src/graphql/client_manifest/projections.rs index 80fb46d9..ada863f7 100644 --- a/src/graphql/client_manifest/projections.rs +++ b/src/graphql/client_manifest/projections.rs @@ -289,6 +289,8 @@ pub(super) fn command_projection_extension( fn_name: reduce.fn_name.clone(), client_module: reduce.client_module.clone(), client_export: reduce.client_export.clone(), + wasm_package: reduce.wasm_package.clone(), + wasm_export: reduce.wasm_export.clone(), model: reduce.model.clone(), key: reduce .key diff --git a/src/graphql/client_manifest/types.rs b/src/graphql/client_manifest/types.rs index a0741e94..04d2b542 100644 --- a/src/graphql/client_manifest/types.rs +++ b/src/graphql/client_manifest/types.rs @@ -854,8 +854,14 @@ pub struct CommandProjectionExtension { #[serde(deny_unknown_fields)] pub struct ClientCommandPureReduce { pub fn_name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] pub client_module: String, + #[serde(default, skip_serializing_if = "String::is_empty")] pub client_export: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub wasm_package: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub wasm_export: String, pub model: String, pub key: Vec, pub args: Vec, diff --git a/src/graphql/command_contract/projections.rs b/src/graphql/command_contract/projections.rs index 76dee3af..ca70a3d7 100644 --- a/src/graphql/command_contract/projections.rs +++ b/src/graphql/command_contract/projections.rs @@ -156,17 +156,23 @@ pub(crate) struct CommandProjectionEventPreview { /// Pure reducer over a known cache row for client auto-optimism. /// -/// The server/domain owns the pure semantics (e.g. `blob_domain::simulate_move`); -/// the client module/export is the shipped TypeScript twin invoked by the -/// replica when applying `projection.pureReduces`. +/// Domain owns pure semantics. Client delivery is either: +/// - **WASM** ([`Self::wasm`]): gen-client emits a `createWasmJsonPure` host in +/// `pures.ts` — no app TypeScript pure file required. +/// - **Hand module** ([`Self::client_module`]): gen-client imports a named export +/// from `$lib/` (escape hatch). #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct CommandProjectionPureReduce { /// Stable pure id, e.g. `blob.simulate_move`. pub fn_name: String, - /// Path under app `$lib` without extension, e.g. `blob/simulate-move`. + /// Path under app `$lib` without extension for a hand-written pure (empty if WASM). pub client_module: String, - /// Named export in that module, e.g. `simulateMove`. + /// Named export in that hand module (empty if WASM). pub client_export: String, + /// wasm-pack package under `$lib` without extension, e.g. `blob/pkg/blob_wasm`. + pub wasm_package: String, + /// Named WASM export `(recordJson, argsJson) -> assignJson | undefined`. + pub wasm_export: String, /// Projection model id (e.g. `BlobGames`). pub model: String, /// Record key fields: `name` is the model field; `source` is input/default/preset. @@ -185,7 +191,8 @@ pub struct CommandProjectionPureArg { } impl CommandProjectionPureReduce { - pub fn new( + /// Hand-written pure under `$lib/` exporting `client_export`. + pub fn client_module( fn_name: impl Into, client_module: impl Into, client_export: impl Into, @@ -195,6 +202,8 @@ impl CommandProjectionPureReduce { fn_name: fn_name.into(), client_module: client_module.into(), client_export: client_export.into(), + wasm_package: String::new(), + wasm_export: String::new(), model: model.into(), key: Vec::new(), args: Vec::new(), @@ -202,6 +211,37 @@ impl CommandProjectionPureReduce { } } + /// Domain pure shipped as wasm-pack under `$lib/`; gen-client hosts it. + pub fn wasm( + fn_name: impl Into, + wasm_package: impl Into, + wasm_export: impl Into, + model: impl Into, + ) -> Self { + Self { + fn_name: fn_name.into(), + client_module: String::new(), + client_export: String::new(), + wasm_package: wasm_package.into(), + wasm_export: wasm_export.into(), + model: model.into(), + key: Vec::new(), + args: Vec::new(), + assign: Vec::new(), + } + } + + /// Deprecated alias for [`Self::client_module`]. + #[deprecated(note = "use client_module() or wasm()")] + pub fn new( + fn_name: impl Into, + client_module: impl Into, + client_export: impl Into, + model: impl Into, + ) -> Self { + Self::client_module(fn_name, client_module, client_export, model) + } + #[must_use] pub fn key_input( mut self, @@ -358,13 +398,36 @@ impl CommandProjectionEvents { } } for reduce in &mut self.pure_reduces { - if reduce.fn_name.trim().is_empty() - || reduce.client_module.trim().is_empty() - || reduce.client_export.trim().is_empty() - || reduce.model.trim().is_empty() + if reduce.fn_name.trim().is_empty() || reduce.model.trim().is_empty() { + return Err(format!( + "typed command `{command}` pure reduce requires non-empty fn and model" + )); + } + let hand = !reduce.client_module.trim().is_empty() + || !reduce.client_export.trim().is_empty(); + let wasm = + !reduce.wasm_package.trim().is_empty() || !reduce.wasm_export.trim().is_empty(); + if hand == wasm { + return Err(format!( + "typed command `{command}` pure reduce `{}` must declare either client_module+client_export or wasm_package+wasm_export (not both, not neither)", + reduce.fn_name + )); + } + if hand + && (reduce.client_module.trim().is_empty() + || reduce.client_export.trim().is_empty()) + { + return Err(format!( + "typed command `{command}` pure reduce `{}` client module requires non-empty client_module and client_export", + reduce.fn_name + )); + } + if wasm + && (reduce.wasm_package.trim().is_empty() || reduce.wasm_export.trim().is_empty()) { return Err(format!( - "typed command `{command}` pure reduce requires non-empty fn, client_module, client_export, and model" + "typed command `{command}` pure reduce `{}` wasm package requires non-empty wasm_package and wasm_export", + reduce.fn_name )); } if reduce.key.is_empty() { diff --git a/tests/e2e-ui/Makefile b/tests/e2e-ui/Makefile index 4afb618c..0427d580 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-domain $(CARGO_TEST_FLAGS) test-suite: cargo test -p e2e-suite --test behavioral $(CARGO_TEST_FLAGS) @@ -140,7 +140,15 @@ js-install: js-build: js-install cd $(JS_DIR) && $(NPM) run build -ui-install: js-build +## 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-domain --target web --out-dir "$$out" --out-name blob_wasm \ + -- --no-default-features --features wasm + +ui-install: js-build wasm cd ui && $(NPM) install ui-build: ui-install @@ -171,13 +179,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-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 +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-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-domain/Cargo.toml b/tests/e2e-ui/crates/blob-domain/Cargo.toml index c6b22f07..d7ef74f5 100644 --- a/tests/e2e-ui/crates/blob-domain/Cargo.toml +++ b/tests/e2e-ui/crates/blob-domain/Cargo.toml @@ -4,13 +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] -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-domain/src/core/direction.rs b/tests/e2e-ui/crates/blob-domain/src/core/direction.rs new file mode 100644 index 00000000..c2f35d8c --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/core/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-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-domain/src/core/simulate.rs b/tests/e2e-ui/crates/blob-domain/src/core/simulate.rs new file mode 100644 index 00000000..240e8be9 --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/core/simulate.rs @@ -0,0 +1,146 @@ +//! Pure post-move board snapshot. + +use super::tile; +use super::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 super::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-domain/src/core/tile.rs b/tests/e2e-ui/crates/blob-domain/src/core/tile.rs new file mode 100644 index 00000000..9b2cb17f --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/core/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/src/lib.rs b/tests/e2e-ui/crates/blob-domain/src/lib.rs index ecaa3728..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: //! -//! 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. +//! - [`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 df0748f3..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,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 `crate::core`, re-exported here. +pub use crate::core::{simulate_move, MovePreview}; -impl MovePreview { - pub fn status(&self) -> String { - status_of(self.player_dead, self.level_complete) +fn map_simulate_err(err: crate::core::SimulateError) -> BlobError { + match err { + crate::core::SimulateError::NoActiveLevel => BlobError::NoActiveLevel, + crate::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..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,31 +1,3 @@ -use serde::{Deserialize, Serialize}; +//! Re-export pure direction from [`crate::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 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 5534c1cb..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,8 +1,3 @@ -//! Canonical tile values (JS parity). +//! Re-export pure tile constants from [`crate::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 crate::core::tile::*; diff --git a/tests/e2e-ui/crates/blob-domain/src/wasm.rs b/tests/e2e-ui/crates/blob-domain/src/wasm.rs new file mode 100644 index 00000000..c8936ad9 --- /dev/null +++ b/tests/e2e-ui/crates/blob-domain/src/wasm.rs @@ -0,0 +1,50 @@ +//! Client WASM surface for pure board rules (`--features wasm`). +//! +//! One export: JSON record + JSON args → JSON assign fields (or undefined). +//! All validation lives here so the TS host stays a thin framework bridge. + +use crate::core::{simulate_move, Direction}; +use serde_json::{Map, Value}; +use wasm_bindgen::prelude::*; + +/// Known-row pure reduce for `blob.simulate_move`. +/// +/// * `record_json` — known `BlobGames` row (needs `map_json`, `score`) +/// * `args_json` — command args (needs `direction`) +/// +/// Returns assign payload: +/// `{ map_json, score, player_dead, current_level_completed, status }` +/// or `undefined` when invalid / impossible (fail closed). +#[wasm_bindgen(js_name = blobSimulateMove)] +pub fn blob_simulate_move(record_json: &str, args_json: &str) -> Option { + let record: Value = serde_json::from_str(record_json).ok()?; + let args: Value = serde_json::from_str(args_json).ok()?; + let map_json = record.get("map_json")?.as_str()?; + let score = json_i64(record.get("score")?)?; + let direction = args.get("direction")?.as_str().and_then(Direction::parse)?; + let map: Vec> = serde_json::from_str(map_json).ok()?; + let preview = simulate_move(&map, score, direction).ok()?; + let next_map_json = serde_json::to_string(&preview.map).ok()?; + + let mut out = Map::new(); + out.insert("map_json".into(), Value::String(next_map_json)); + out.insert("score".into(), Value::from(preview.score)); + out.insert("player_dead".into(), Value::Bool(preview.player_dead)); + out.insert( + "current_level_completed".into(), + Value::Bool(preview.level_complete), + ); + out.insert("status".into(), Value::String(preview.status())); + serde_json::to_string(&Value::Object(out)).ok() +} + +fn json_i64(value: &Value) -> Option { + match value { + Value::Number(n) => n + .as_i64() + .or_else(|| n.as_u64().and_then(|u| i64::try_from(u).ok())) + .or_else(|| n.as_f64().filter(|f| f.is_finite()).map(|f| f as i64)), + Value::String(s) => s.parse().ok(), + _ => None, + } +} diff --git a/tests/e2e-ui/crates/service/src/modules/blob.rs b/tests/e2e-ui/crates/service/src/modules/blob.rs index b7318e54..02105165 100644 --- a/tests/e2e-ui/crates/service/src/modules/blob.rs +++ b/tests/e2e-ui/crates/service/src/modules/blob.rs @@ -64,12 +64,12 @@ 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_domain::core — WASM package under $lib; gen-client hosts it. .preview_reduce_known_record( - CommandProjectionPureReduce::new( + CommandProjectionPureReduce::wasm( "blob.simulate_move", - "blob/simulate-move", - "simulateMove", + "blob/pkg/blob_wasm", + "blobSimulateMove", "BlobGames", ) .key_input("game_id", ["game_id"]) diff --git a/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts b/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts deleted file mode 100644 index 46a3cc10..00000000 --- a/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * 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. - * - * Registered as pure function `blob.simulate_move` on the command runtime. - */ - -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; - player_dead: boolean; - current_level_completed: boolean; - status: string; -}>; - -/** - * Apply one direction to a known BlobGames row. - * Returns null when the move is impossible (edge/no map) so optimism fails closed. - */ -export function simulateMove( - record: Readonly>, - args: Readonly> -): BlobMoveResult | 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' - ? scoreRaw - : typeof scoreRaw === 'bigint' - ? Number(scoreRaw) - : typeof scoreRaw === 'string' - ? Number(scoreRaw) - : 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]; - } - } - 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: - return null; - } -} - -/** Pure registry entry for the command runtime. */ -export const BLOB_PURE_FUNCTIONS = Object.freeze({ - 'blob.simulate_move': simulateMove -}); 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..2775100e 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}