diff --git a/.github/workflows/swift-procedure-e2e.yml b/.github/workflows/swift-procedure-e2e.yml new file mode 100644 index 00000000000..838d395d477 --- /dev/null +++ b/.github/workflows/swift-procedure-e2e.yml @@ -0,0 +1,82 @@ +name: Swift Procedure E2E + +on: + pull_request: + paths: + - sdks/swift/** + - crates/codegen/src/swift.rs + - crates/codegen/src/lib.rs + - crates/cli/src/subcommands/generate.rs + - modules/module-test/** + - tools/swift-procedure-e2e.sh + - tools/check-swift-demo-bindings.sh + - .github/workflows/swift-procedure-e2e.yml + push: + branches: + - master + paths: + - sdks/swift/** + - crates/codegen/src/swift.rs + - crates/codegen/src/lib.rs + - crates/cli/src/subcommands/generate.rs + - modules/module-test/** + - tools/swift-procedure-e2e.sh + - tools/check-swift-demo-bindings.sh + - .github/workflows/swift-procedure-e2e.yml + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || format('sha-{0}', github.sha) }} + cancel-in-progress: true + +jobs: + swift-procedure-e2e: + name: Swift Procedure E2E (macOS) + runs-on: macos-latest + timeout-minutes: 45 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dsherret/rust-toolchain-file@v1 + + - name: Set default rust toolchain + run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: ${{ github.workspace }} + shared-key: spacetimedb + save-if: false + prefix-key: v1 + + - name: Install SpacetimeDB CLI from local checkout + run: | + export CARGO_HOME="$HOME/.cargo" + echo "$CARGO_HOME/bin" >> "$GITHUB_PATH" + cargo install --force --path crates/cli --locked --message-format=short + cargo install --force --path crates/standalone --features allow_loopback_http_for_tests --locked --message-format=short + ln -sf "$CARGO_HOME/bin/spacetimedb-cli" "$CARGO_HOME/bin/spacetime" + + - name: Show toolchain versions + run: | + rustc --version + cargo --version + swiftc --version + spacetime --version + + - name: Verify generated Swift demo bindings are in sync + run: tools/check-swift-demo-bindings.sh + + - name: Start local SpacetimeDB server + run: | + spacetime start & + disown + sleep 5 + + - name: Run Swift procedure callback E2E + run: tools/swift-procedure-e2e.sh diff --git a/.github/workflows/swift-sdk.yml b/.github/workflows/swift-sdk.yml new file mode 100644 index 00000000000..a63020aa90b --- /dev/null +++ b/.github/workflows/swift-sdk.yml @@ -0,0 +1,114 @@ +name: Swift SDK + +on: + pull_request: + paths: + - sdks/swift/** + - demo/ninja-game/client-swift/** + - demo/simple-module/client-swift/** + - tools/swift-procedure-e2e.sh + - tools/swift-benchmark-smoke.sh + - tools/swift-docc-smoke.sh + - .github/workflows/swift-sdk.yml + push: + branches: + - master + paths: + - sdks/swift/** + - demo/ninja-game/client-swift/** + - demo/simple-module/client-swift/** + - tools/swift-procedure-e2e.sh + - tools/swift-benchmark-smoke.sh + - tools/swift-docc-smoke.sh + - .github/workflows/swift-sdk.yml + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || format('sha-{0}', github.sha) }} + cancel-in-progress: true + +jobs: + swift-sdk: + name: Swift SDK (${{ matrix.platform.name }}) + runs-on: ${{ matrix.platform.runs_on }} + timeout-minutes: 45 + env: + SPACETIMEDB_SWIFT_LIVE_TESTS: ${{ vars.SPACETIMEDB_SWIFT_LIVE_TESTS }} + SPACETIMEDB_LIVE_TEST_SERVER_URL: ${{ vars.SPACETIMEDB_LIVE_TEST_SERVER_URL }} + SPACETIMEDB_LIVE_TEST_DB_NAME: ${{ vars.SPACETIMEDB_LIVE_TEST_DB_NAME }} + SPACETIMEDB_LIVE_TEST_TOKEN: ${{ secrets.SPACETIMEDB_LIVE_TEST_TOKEN }} + strategy: + fail-fast: false + matrix: + platform: + - name: macOS + id: macos + runs_on: macos-latest + - name: iOS Simulator + id: ios-simulator + runs_on: macos-latest + + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Show Swift toolchain version + run: swift --version + + - name: Assert legacy Swift SDK path is removed + shell: bash + run: | + if git grep -n "crates/swift-sdk" -- . ':(exclude).github/workflows/swift-sdk.yml'; then + echo "Found legacy path references to crates/swift-sdk; expected sdks/swift." + exit 1 + fi + + - name: Test Swift SDK package + if: matrix.platform.id == 'macos' + run: swift test --package-path sdks/swift + + - name: Validate Swift package lockfile + if: matrix.platform.id == 'macos' + run: swift package --package-path sdks/swift resolve --force-resolved-versions + + - name: Build simple Swift demo client + if: matrix.platform.id == 'macos' + run: swift build --package-path demo/simple-module/client-swift + + - name: Build ninja Swift demo client + if: matrix.platform.id == 'macos' + run: swift build --package-path demo/ninja-game/client-swift + + - name: Run Swift benchmark smoke suite + if: matrix.platform.id == 'macos' + run: tools/swift-benchmark-smoke.sh + + - name: Run Swift SDK live integration tests (env-gated) + if: matrix.platform.id == 'macos' && env.SPACETIMEDB_SWIFT_LIVE_TESTS == '1' && env.SPACETIMEDB_LIVE_TEST_DB_NAME != '' + run: swift test --package-path sdks/swift --filter LiveIntegrationTests + + - name: Build Swift SDK target for iOS simulator + if: matrix.platform.id == 'ios-simulator' + shell: bash + run: | + IOS_SDK_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)" + swift build \ + --package-path sdks/swift \ + --target SpacetimeDB \ + --triple arm64-apple-ios17.0-simulator \ + --sdk "$IOS_SDK_PATH" + + - name: Assert visionOS is not targeted yet + if: matrix.platform.id == 'macos' + shell: bash + run: | + if rg -q '\.visionOS\(' sdks/swift/Package.swift; then + echo "visionOS platform is currently not supported for this SDK." + echo "If enabling visionOS, update README/DocC/PUBLISHING docs and CI posture in the same change." + exit 1 + fi + echo "visionOS platform not declared in Package.swift (expected posture)." + + - name: Build DocC archive + if: matrix.platform.id == 'macos' + run: tools/swift-docc-smoke.sh diff --git a/.gitignore b/.gitignore index debd5385bfd..c5fa0944ec9 100644 --- a/.gitignore +++ b/.gitignore @@ -264,3 +264,11 @@ nul # AI agent config .codex .claude + +# Local PR/planning notes +changes.md +swift_sdk_followup_backlog.md + +# Swift benchmark local artifacts +sdks/swift/.benchmarkBaselines/ +sdks/swift/Benchmarks/Baselines/captures/ diff --git a/High Level Goals.md b/High Level Goals.md new file mode 100644 index 00000000000..6dbf952d3fb --- /dev/null +++ b/High Level Goals.md @@ -0,0 +1,121 @@ +# High Level Goals + +## Program Context + +We are delivering first-class Swift support for SpacetimeDB inside the monorepo, then upstreaming it as an open-source PR. + +Work is happening on branch `swift-integration` and includes: + +- Swift runtime SDK (`sdks/swift`) +- Swift code generation in Rust/CLI (`crates/codegen`, `crates/cli`) +- End-to-end Swift demos (`demo/simple-module/client-swift`, `demo/ninja-game/client-swift`) +- CI, tooling, docs, and distribution hardening for Apple app teams + +## Updated Product Direction + +The goal is a pure Swift, lightweight, high-performance realtime client SDK for Apple development. + +Important scope clarification: + +- The SDK is rendering-agnostic and does not depend on Metal. +- Demo rendering is currently SwiftUI/Canvas-based. +- The key value is low-latency, reactive state replication into native Swift app/game loops. + +## End-State Goals + +1. Ship a production-credible Swift SDK in the monorepo and upstream PR. +2. Keep runtime path pure Swift with no third-party runtime dependency burden. +3. Provide typed Swift bindings from SpacetimeDB schema via official CLI generation. +4. Prove end-to-end realtime behavior with native Swift demo clients. +5. Add packaging, documentation, benchmarks, and CI guardrails so Apple teams can trust releases. + +## Workstreams + +### 1) Swift Runtime SDK + +Goal: stable native runtime with robust transport, cache, and API ergonomics. + +Target outcomes: + +- BSATN encoding/decoding and v2 protocol support +- websocket transport with reconnect and keepalive handling +- local replica cache with table delta callbacks +- reducer/procedure/query APIs (callback + async/await) +- token persistence and observability hooks + +### 2) Swift Code Generation + +Goal: avoid hand-written schema glue; generate strongly-typed Swift APIs. + +Target outcomes: + +- `spacetime generate --lang swift` support in official CLI +- generated rows/tables/reducers/procedures/module registration +- binding-drift checks in CI for demo artifacts + +### 3) Demo Validation Apps + +Goal: prove practical integration and realtime behavior in native Swift clients. + +Target outcomes: + +- `simple-module` demo validates connect/add/delete/replica updates +- `ninja-game` demo validates higher-frequency multiplayer state updates and gameplay loops +- both demos build and stay in sync with generated bindings + +### 4) Apple Ecosystem Hardening + +Goal: make the package consumable and trustworthy for app teams. + +Target outcomes: + +- DocC docs/tutorials and publishing guides +- Apple CI matrix (macOS + iOS simulator; explicit current visionOS posture) +- package benchmark suite and baseline capture tooling +- mirror/release automation for package-root SPI/SPM distribution + +## Current Status Snapshot + +Broadly complete: + +- Native Swift runtime package with tests +- Swift codegen backend and CLI integration +- Two Swift demo clients in-repo +- CI coverage for tests/builds/drift/E2E/bench smoke/docs smoke +- Observability, keepalive/state surface, connectivity-aware reconnect +- Keychain token utility +- Benchmark suite + baseline tooling +- DocC + SPI config + distribution runbooks + +Remaining high-priority external step: + +- Submit Swift mirror package repository to Swift Package Index and verify docs/platform badges + +## PR Success Criteria + +1. Branch demonstrates complete Swift SDK path (runtime + codegen + demos + CI). +2. Validation matrix is green and reproducible. +3. Documentation reflects actual behavior and support posture. +4. PR narrative (`changes.md`) is comprehensive and accurate. +5. Scope claims match implementation (native Swift realtime SDK, not a Metal-specific SDK). + +## Principles + +- Runtime-first correctness over UI-specific coupling. +- Pure Swift integration path for Apple developers. +- Strong typing via generation, not manual schema translation. +- CI-enforced reproducibility and drift prevention. +- Clear support posture and release process documentation. + +## Near-Term Next Goals + +1. Execute manual SPI submission for mirror repo and verify badge endpoints. +2. Finalize any remaining TODO audit items and reflect them in backlog docs. +3. Keep Swift parity improvements scoped and test-backed (builder ergonomics, optional helper APIs). +4. Revisit visionOS support when trigger criteria in `sdks/swift/VISIONOS_ROADMAP.md` are met. + +## Non-Goals (Current Phase) + +- Building a Metal-specific rendering SDK layer. +- Coupling SDK internals to any single game/UI framework. +- Expanding platform support claims beyond documented CI-backed posture. diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index 0e7c896d34c..05ee4a790a5 100644 --- a/crates/cli/src/subcommands/generate.rs +++ b/crates/cli/src/subcommands/generate.rs @@ -6,8 +6,8 @@ use clap::Arg; use clap::ArgAction::{Set, SetTrue}; use fs_err as fs; use spacetimedb_codegen::{ - generate, private_table_names, CodegenOptions, CodegenVisibility, Csharp, Lang, OutputFile, Rust, TypeScript, - UnrealCpp, AUTO_GENERATED_PREFIX, + generate, private_table_names, CodegenOptions, CodegenVisibility, Csharp, Lang, OutputFile, Rust, Swift, + TypeScript, UnrealCpp, AUTO_GENERATED_PREFIX, }; use spacetimedb_lib::de::serde::DeserializeWrapper; use spacetimedb_lib::{sats, RawModuleDef}; @@ -387,6 +387,9 @@ fn detect_default_language(client_project_dir: &Path) -> anyhow::Result &'static str { Language::Csharp => "csharp", Language::TypeScript => "typescript", Language::UnrealCpp => "unrealcpp", + Language::Swift => "swift", } } @@ -417,6 +421,7 @@ pub fn default_out_dir_for_language(lang: Language) -> Option { Language::Rust | Language::TypeScript => Some(PathBuf::from("src/module_bindings")), Language::Csharp => Some(PathBuf::from("module_bindings")), Language::UnrealCpp => None, + Language::Swift => Some(PathBuf::from("Sources/module_bindings")), } } @@ -427,6 +432,16 @@ pub fn resolve_language(module_path: &Path, requested: Option) -> anyh } } +fn file_starts_with_auto_generated_prefix(path: &Path) -> anyhow::Result { + let mut auto_generated_buf = [0_u8; AUTO_GENERATED_PREFIX.len()]; + let mut file = fs::File::open(path)?; + match file.read_exact(&mut auto_generated_buf) { + Ok(()) => Ok(auto_generated_buf == AUTO_GENERATED_PREFIX.as_bytes()), + Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false), + Err(err) => Err(err.into()), + } +} + pub fn build_generate_entry( module_path: Option<&Path>, language: Option, @@ -501,6 +516,7 @@ pub async fn run_prepared_generate_configs( let csharp_lang; let unreal_cpp_lang; + let swift_lang; let gen_lang = match run.lang { Language::Csharp => { csharp_lang = Csharp { @@ -515,6 +531,10 @@ pub async fn run_prepared_generate_configs( }; &unreal_cpp_lang as &dyn Lang } + Language::Swift => { + swift_lang = Swift; + &swift_lang as &dyn Lang + } Language::Rust => &Rust, Language::TypeScript => &TypeScript, }; @@ -538,7 +558,6 @@ pub async fn run_prepared_generate_configs( _ => run.out_dir.clone(), }; - let mut auto_generated_buf: [u8; AUTO_GENERATED_PREFIX.len()] = [0; AUTO_GENERATED_PREFIX.len()]; let files_to_delete = walkdir::WalkDir::new(&cleanup_root) .into_iter() .map(|entry_result| { @@ -550,12 +569,7 @@ pub async fn run_prepared_generate_configs( if paths.contains(&path) { return Ok(None); } - let mut file = fs::File::open(&path)?; - Ok(match file.read_exact(&mut auto_generated_buf) { - Ok(()) => (auto_generated_buf == AUTO_GENERATED_PREFIX.as_bytes()).then_some(path), - Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => None, - Err(err) => return Err(err.into()), - }) + Ok(file_starts_with_auto_generated_prefix(&path)?.then_some(path)) }) .filter_map(Result::transpose) .collect::>>()?; @@ -679,11 +693,12 @@ pub enum Language { Rust, #[serde(alias = "uecpp", alias = "ue5cpp", alias = "unreal")] UnrealCpp, + Swift, } impl clap::ValueEnum for Language { fn value_variants<'a>() -> &'a [Self] { - &[Self::Csharp, Self::TypeScript, Self::Rust, Self::UnrealCpp] + &[Self::Csharp, Self::TypeScript, Self::Rust, Self::UnrealCpp, Self::Swift] } fn to_possible_value(&self) -> Option { Some(match self { @@ -691,6 +706,7 @@ impl clap::ValueEnum for Language { Self::TypeScript => clap::builder::PossibleValue::new("typescript").aliases(["ts", "TS"]), Self::Rust => clap::builder::PossibleValue::new("rust").aliases(["rs", "RS"]), Self::UnrealCpp => PossibleValue::new("unrealcpp").aliases(["uecpp", "ue5cpp", "unreal"]), + Self::Swift => PossibleValue::new("swift"), }) } } @@ -703,6 +719,7 @@ impl Language { Language::Csharp => "C#", Language::TypeScript => "TypeScript", Language::UnrealCpp => "Unreal C++", + Language::Swift => "Swift", } } @@ -716,12 +733,74 @@ impl Language { Language::UnrealCpp => { // TODO: implement formatting. } + Language::Swift => swift_format(project_dir, generated_files)?, } Ok(()) } } +fn swift_format(project_dir: &Path, files: impl IntoIterator) -> anyhow::Result<()> { + let cwd = std::env::current_dir().context("Failed to retrieve current directory")?; + let files: Vec = files + .into_iter() + .filter(|file| file.extension().is_some_and(|ext| ext == "swift")) + .map(|file| { + let file = if file.is_absolute() { file } else { cwd.join(file) }; + file.canonicalize() + .with_context(|| format!("Failed to canonicalize generated file path: {}", file.display())) + }) + .collect::>()?; + + if files.is_empty() { + return Ok(()); + } + + let swift_supports_format = Command::new("swift") + .args(["format", "format", "--help"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + + if swift_supports_format { + let status = Command::new("swift") + .current_dir(project_dir) + .args(["format", "format", "--in-place", "--parallel"]) + .args(files.iter()) + .status() + .context("Failed to run `swift format format`")?; + if status.success() { + return Ok(()); + } + anyhow::bail!("`swift format format` failed with status: {status}"); + } + + let swift_format_supports_format = Command::new("swift-format") + .args(["format", "--help"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + + if swift_format_supports_format { + let status = Command::new("swift-format") + .current_dir(project_dir) + .args(["format", "--in-place", "--parallel"]) + .args(files.iter()) + .status() + .context("Failed to run `swift-format format`")?; + if status.success() { + return Ok(()); + } + anyhow::bail!("`swift-format format` failed with status: {status}"); + } + + anyhow::bail!( + "No Swift formatter found. Install a Swift toolchain with `swift format` support, or install `swift-format`." + ) +} + pub type ExtractDescriptions = fn(&Path) -> anyhow::Result; pub fn extract_descriptions(wasm_file: &Path) -> anyhow::Result { let bin_path = resolve_sibling_binary("spacetimedb-standalone")?; @@ -835,7 +914,7 @@ mod tests { let mut child_fields = HashMap::new(); child_fields.insert("database".to_string(), serde_json::json!("my-db")); - let gen = { + let generate_entry = { let mut m = HashMap::new(); m.insert("language".to_string(), serde_json::json!("rust")); m.insert("out_dir".to_string(), serde_json::json!("/tmp/out")); @@ -844,7 +923,7 @@ mod tests { let spacetime_config = SpacetimeConfig { additional_fields: parent_fields, - children: Some(vec![make_gen_config(child_fields, vec![gen])]), + children: Some(vec![make_gen_config(child_fields, vec![generate_entry])]), ..Default::default() }; @@ -864,7 +943,7 @@ mod tests { let cmd = cli(); let schema = build_generate_config_schema(&cmd).unwrap(); - let gen = { + let generate_entry = { let mut m = HashMap::new(); m.insert("language".to_string(), serde_json::json!("typescript")); m.insert("out_dir".to_string(), serde_json::json!("/tmp/out")); @@ -876,7 +955,7 @@ mod tests { let spacetime_config = SpacetimeConfig { additional_fields: parent_fields, - generate: Some(vec![gen]), + generate: Some(vec![generate_entry]), children: Some(vec![ { let mut f = HashMap::new(); @@ -1070,6 +1149,24 @@ mod tests { assert_eq!(runs[0].out_dir, PathBuf::from("module_bindings")); } + #[test] + fn test_swift_defaults_out_dir() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + let matches = cmd.clone().get_matches_from(vec!["generate", "--lang", "swift"]); + let temp = tempfile::TempDir::new().unwrap(); + let module_dir = temp.path().join("spacetimedb"); + std::fs::create_dir_all(&module_dir).unwrap(); + let mut cfg = HashMap::new(); + cfg.insert( + "module-path".to_string(), + serde_json::Value::String(module_dir.display().to_string()), + ); + let command_config = CommandConfig::new(&schema, cfg, &matches).unwrap(); + let runs = prepare_generate_run_configs(vec![command_config], false, None).unwrap(); + assert_eq!(runs[0].out_dir, PathBuf::from("Sources/module_bindings")); + } + #[test] fn test_detect_typescript_language_from_client_project() { let cmd = cli(); @@ -1110,6 +1207,50 @@ mod tests { assert_eq!(runs[0].out_dir, temp.path().join("module_bindings")); } + #[test] + fn test_detect_swift_language_from_client_project() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + let matches = cmd.clone().get_matches_from(vec!["generate"]); + let temp = tempfile::TempDir::new().unwrap(); + let module_dir = temp.path().join("spacetimedb"); + std::fs::create_dir_all(&module_dir).unwrap(); + std::fs::write( + temp.path().join("Package.swift"), + "import PackageDescription\nlet package = Package(name: \"Client\")\n", + ) + .unwrap(); + let mut cfg = HashMap::new(); + cfg.insert( + "module-path".to_string(), + serde_json::Value::String(module_dir.display().to_string()), + ); + let command_config = CommandConfig::new(&schema, cfg, &matches).unwrap(); + let runs = prepare_generate_run_configs(vec![command_config], true, Some(temp.path())).unwrap(); + assert_eq!(runs[0].lang, Language::Swift); + assert_eq!(runs[0].out_dir, temp.path().join("Sources/module_bindings")); + } + + #[test] + fn test_file_starts_with_auto_generated_prefix() { + let temp = tempfile::TempDir::new().unwrap(); + let generated = temp.path().join("generated.swift"); + let manual = temp.path().join("manual.swift"); + let short = temp.path().join("short.swift"); + + std::fs::write( + &generated, + format!("{AUTO_GENERATED_PREFIX}. EDITS TO THIS FILE\n// WILL NOT BE SAVED.\nimport Foundation\n"), + ) + .unwrap(); + std::fs::write(&manual, "import Foundation\n").unwrap(); + std::fs::write(&short, "// THIS\n").unwrap(); + + assert!(file_starts_with_auto_generated_prefix(&generated).unwrap()); + assert!(!file_starts_with_auto_generated_prefix(&manual).unwrap()); + assert!(!file_starts_with_auto_generated_prefix(&short).unwrap()); + } + #[test] fn test_error_when_default_module_path_missing_and_lang_not_set() { let cmd = cli(); @@ -1217,7 +1358,7 @@ mod tests { let cmd = cli(); let schema = build_generate_config_schema(&cmd).unwrap(); - let gen = { + let generate_entry = { let mut m = HashMap::new(); m.insert("language".to_string(), serde_json::json!("typescript")); m.insert("out_dir".to_string(), serde_json::json!("/tmp/bindings")); @@ -1229,7 +1370,7 @@ mod tests { let spacetime_config = SpacetimeConfig { additional_fields: parent_fields, - generate: Some(vec![gen]), + generate: Some(vec![generate_entry]), children: Some(vec![ { let mut f = HashMap::new(); @@ -1268,7 +1409,7 @@ mod tests { let cmd = cli(); let schema = build_generate_config_schema(&cmd).unwrap(); - let gen = { + let generate_entry = { let mut m = HashMap::new(); m.insert("language".to_string(), serde_json::json!("rust")); m.insert("out_dir".to_string(), serde_json::json!("/tmp/out")); @@ -1284,7 +1425,7 @@ mod tests { m.insert("module-path".to_string(), serde_json::json!("./m1")); m }, - vec![gen.clone()], + vec![generate_entry.clone()], ), make_gen_config( { @@ -1293,7 +1434,7 @@ mod tests { m.insert("module-path".to_string(), serde_json::json!("./m2")); m }, - vec![gen.clone()], + vec![generate_entry.clone()], ), make_gen_config( { @@ -1302,7 +1443,7 @@ mod tests { m.insert("module-path".to_string(), serde_json::json!("./m3")); m }, - vec![gen], + vec![generate_entry], ), ]), ..Default::default() @@ -1321,7 +1462,7 @@ mod tests { let cmd = cli(); let schema = build_generate_config_schema(&cmd).unwrap(); - let gen = { + let generate_entry = { let mut m = HashMap::new(); m.insert("language".to_string(), serde_json::json!("rust")); m.insert("out_dir".to_string(), serde_json::json!("/tmp/out")); @@ -1335,7 +1476,7 @@ mod tests { m.insert("module-path".to_string(), serde_json::json!("./server")); m }, - vec![gen], + vec![generate_entry], ); let matches = cmd.clone().get_matches_from(vec!["generate", "nonexistent-*"]); diff --git a/crates/codegen/src/lib.rs b/crates/codegen/src/lib.rs index 28d4fb8a5a4..2514cf72621 100644 --- a/crates/codegen/src/lib.rs +++ b/crates/codegen/src/lib.rs @@ -4,12 +4,14 @@ mod code_indenter; pub mod cpp; pub mod csharp; pub mod rust; +pub mod swift; pub mod typescript; pub mod unrealcpp; mod util; pub use self::csharp::Csharp; pub use self::rust::Rust; +pub use self::swift::Swift; pub use self::typescript::TypeScript; pub use self::unrealcpp::UnrealCpp; pub use util::private_table_names; diff --git a/crates/codegen/src/swift.rs b/crates/codegen/src/swift.rs new file mode 100644 index 00000000000..11bcaf409ca --- /dev/null +++ b/crates/codegen/src/swift.rs @@ -0,0 +1,668 @@ +use super::util::{collect_case, type_ref_name, AUTO_GENERATED_PREFIX}; +use super::Lang; +use crate::util::iter_table_names_and_types; +use crate::{CodegenOptions, OutputFile}; +use convert_case::{Case, Casing}; +use spacetimedb_lib::sats::layout::PrimitiveType; +use spacetimedb_schema::def::{ModuleDef, ProcedureDef, ReducerDef, TableDef, TypeDef}; +use spacetimedb_schema::schema::TableSchema; +use spacetimedb_schema::type_for_generate::{AlgebraicTypeDef, AlgebraicTypeUse}; +use std::fmt::Write; +use std::ops::Deref; + +pub struct Swift; + +fn write_generated_file_preamble(code: &mut String) { + writeln!(&mut *code, "{AUTO_GENERATED_PREFIX}. EDITS TO THIS FILE").unwrap(); + writeln!( + &mut *code, + "// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD." + ) + .unwrap(); + writeln!(&mut *code).unwrap(); +} + +fn get_swift_type_for_primitive(prim: PrimitiveType) -> &'static str { + match prim { + PrimitiveType::Bool => "Bool", + PrimitiveType::I8 => "Int8", + PrimitiveType::U8 => "UInt8", + PrimitiveType::I16 => "Int16", + PrimitiveType::U16 => "UInt16", + PrimitiveType::I32 => "Int32", + PrimitiveType::U32 => "UInt32", + PrimitiveType::I64 => "Int64", + PrimitiveType::U64 => "UInt64", + PrimitiveType::I128 => "String", // Represent as decimal string for cross-toolchain compatibility. + PrimitiveType::U128 => "String", // Represent as decimal string for cross-toolchain compatibility. + PrimitiveType::I256 => "String", // Swift doesn't have native 256 bigint + PrimitiveType::U256 => "String", + PrimitiveType::F32 => "Float", + PrimitiveType::F64 => "Double", + } +} + +fn get_swift_type_use(module: &ModuleDef, ty: &AlgebraicTypeUse) -> String { + match ty { + AlgebraicTypeUse::Primitive(prim) => get_swift_type_for_primitive(*prim).to_string(), + AlgebraicTypeUse::Ref(type_ref) => { + let name = type_ref_name(module, *type_ref); + match name.as_str() { + "SpacetimeDB.Math.Vector2" | "Vector2" => "simd_float2".to_string(), + "SpacetimeDB.Math.Vector3" | "Vector3" => "simd_float3".to_string(), + "SpacetimeDB.Math.Vector4" | "Vector4" => "simd_float4".to_string(), + "SpacetimeDB.Math.Quaternion" | "Quaternion" => "simd_quatf".to_string(), + _ => name, + } + }, + AlgebraicTypeUse::Identity => "Identity".to_string(), // Requires Identity SDK struct + AlgebraicTypeUse::String => "String".to_string(), + AlgebraicTypeUse::Array(inner) => format!("[{}]", get_swift_type_use(module, inner)), + AlgebraicTypeUse::Option(inner) => format!("{}?", get_swift_type_use(module, inner)), + AlgebraicTypeUse::Result { ok_ty, err_ty } => format!( + "SpacetimeResult<{}, {}>", + get_swift_type_use(module, ok_ty), + get_swift_type_use(module, err_ty) + ), + AlgebraicTypeUse::Unit => "()".to_string(), + AlgebraicTypeUse::Never => "Never".to_string(), + AlgebraicTypeUse::ConnectionId => "ClientConnectionId".to_string(), + AlgebraicTypeUse::ScheduleAt => "ScheduleAt".to_string(), // Requires ScheduleAt SDK struct + AlgebraicTypeUse::Timestamp => "UInt64".to_string(), + AlgebraicTypeUse::TimeDuration => "UInt64".to_string(), + AlgebraicTypeUse::Uuid => "String".to_string(), // Usually treated as string in iOS without further casting + } +} + +fn get_swift_decode_expr(module: &ModuleDef, ty: &AlgebraicTypeUse, reader_expr: &str) -> String { + match ty { + AlgebraicTypeUse::Primitive(prim) => match prim { + PrimitiveType::Bool => format!("try {reader_expr}.readBool()"), + PrimitiveType::I8 => format!("try {reader_expr}.readI8()"), + PrimitiveType::U8 => format!("try {reader_expr}.readU8()"), + PrimitiveType::I16 => format!("try {reader_expr}.readI16()"), + PrimitiveType::U16 => format!("try {reader_expr}.readU16()"), + PrimitiveType::I32 => format!("try {reader_expr}.readI32()"), + PrimitiveType::U32 => format!("try {reader_expr}.readU32()"), + PrimitiveType::I64 => format!("try {reader_expr}.readI64()"), + PrimitiveType::U64 => format!("try {reader_expr}.readU64()"), + PrimitiveType::I128 | PrimitiveType::U128 | PrimitiveType::I256 | PrimitiveType::U256 => { + format!("try {reader_expr}.readString()") + } + PrimitiveType::F32 => format!("try {reader_expr}.readFloat()"), + PrimitiveType::F64 => format!("try {reader_expr}.readDouble()"), + }, + AlgebraicTypeUse::String | AlgebraicTypeUse::Uuid => format!("try {reader_expr}.readString()"), + AlgebraicTypeUse::Timestamp | AlgebraicTypeUse::TimeDuration => { + format!("try {reader_expr}.readU64()") + } + AlgebraicTypeUse::Unit => "()".to_string(), + AlgebraicTypeUse::Never => "fatalError(\"Never cannot be decoded\")".to_string(), + _ => { + let swift_ty = get_swift_type_use(module, ty); + format!("try {swift_ty}.decodeBSATN(from: &{reader_expr})") + } + } +} + +fn get_swift_encode_stmt(module: &ModuleDef, ty: &AlgebraicTypeUse, value_expr: &str, storage_expr: &str) -> String { + match ty { + AlgebraicTypeUse::Primitive(prim) => match prim { + PrimitiveType::Bool => format!("{storage_expr}.appendBool({value_expr})"), + PrimitiveType::I8 => format!("{storage_expr}.appendI8({value_expr})"), + PrimitiveType::U8 => format!("{storage_expr}.appendU8({value_expr})"), + PrimitiveType::I16 => format!("{storage_expr}.appendI16({value_expr})"), + PrimitiveType::U16 => format!("{storage_expr}.appendU16({value_expr})"), + PrimitiveType::I32 => format!("{storage_expr}.appendI32({value_expr})"), + PrimitiveType::U32 => format!("{storage_expr}.appendU32({value_expr})"), + PrimitiveType::I64 => format!("{storage_expr}.appendI64({value_expr})"), + PrimitiveType::U64 => format!("{storage_expr}.appendU64({value_expr})"), + PrimitiveType::I128 | PrimitiveType::U128 | PrimitiveType::I256 | PrimitiveType::U256 => { + format!("try {storage_expr}.appendString({value_expr})") + } + PrimitiveType::F32 => format!("{storage_expr}.appendFloat({value_expr})"), + PrimitiveType::F64 => format!("{storage_expr}.appendDouble({value_expr})"), + }, + AlgebraicTypeUse::String | AlgebraicTypeUse::Uuid => { + format!("try {storage_expr}.appendString({value_expr})") + } + AlgebraicTypeUse::Timestamp | AlgebraicTypeUse::TimeDuration => { + format!("{storage_expr}.appendU64({value_expr})") + } + AlgebraicTypeUse::Unit => "// Unit value carries no payload.".to_string(), + AlgebraicTypeUse::Never => "fatalError(\"Never cannot be encoded\")".to_string(), + _ => { + let _swift_ty = get_swift_type_use(module, ty); + format!("try {value_expr}.encodeBSATN(to: &{storage_expr})") + } + } +} + +impl Lang for Swift { + fn generate_table_file_from_schema( + &self, + module: &ModuleDef, + table: &TableDef, + _schema: TableSchema, + ) -> OutputFile { + let table_name = table.name.deref(); + let table_name_pascal = table.accessor_name.deref().to_case(Case::Pascal); + let row_type = type_ref_name(module, table.product_type_ref); + + let mut code = String::new(); + write_generated_file_preamble(&mut code); + writeln!(&mut code, "import Foundation").unwrap(); + writeln!(&mut code, "import simd").unwrap(); + writeln!(&mut code, "import SpacetimeDB\n").unwrap(); + writeln!(&mut code, "public struct {}Table {{", table_name_pascal).unwrap(); + + // Expose a public cache accessor that UI can subscribe to + writeln!( + &mut code, + " @MainActor public static var cache: TableCache<{}> {{", + row_type + ) + .unwrap(); + writeln!( + &mut code, + " return SpacetimeClient.clientCache.getTableCache(tableName: \"{}\")", + table_name + ) + .unwrap(); + writeln!(&mut code, " }}").unwrap(); + + // Write the generic index mapping for any Unique constraints + writeln!(&mut code, "}}").unwrap(); + + if let Some(pk_col) = table.primary_key { + if let AlgebraicTypeDef::Product(product) = &module.typespace_for_generate()[table.product_type_ref] { + let pk_field_name = product.elements[pk_col.idx() as usize].0.deref().to_case(Case::Camel); + let pk_swift_ty = get_swift_type_use(module, &product.elements[pk_col.idx() as usize].1); + if pk_field_name == "id" { + writeln!(&mut code, "\nextension {}: Identifiable {{}}", row_type).unwrap(); + } else { + writeln!( + &mut code, + "\nextension {}: Identifiable {{\n public var id: {} {{\n return self.{}\n }}\n}}", + row_type, pk_swift_ty, pk_field_name + ).unwrap(); + } + } + } + + OutputFile { + filename: format!("{}Table.swift", table_name_pascal), + code, + } + } + + fn generate_type_files(&self, module: &ModuleDef, typ: &TypeDef) -> Vec { + let type_name = collect_case(Case::Pascal, typ.accessor_name.name_segments()); + let mut code = String::new(); + + write_generated_file_preamble(&mut code); + writeln!(&mut code, "import Foundation").unwrap(); + writeln!(&mut code, "import simd").unwrap(); + writeln!(&mut code, "import SpacetimeDB\n").unwrap(); + + match &module.typespace_for_generate()[typ.ty] { + AlgebraicTypeDef::Product(product) => { + writeln!( + &mut code, + "public struct {}: Codable, Sendable, BSATNSpecialDecodable, BSATNSpecialEncodable {{", + type_name + ) + .unwrap(); + for (name, ty) in &product.elements { + let field_name = name.deref().to_case(Case::Camel); + let swift_ty = get_swift_type_use(module, ty); + writeln!(&mut code, " public var {}: {}", field_name, swift_ty).unwrap(); + } + writeln!(&mut code, "").unwrap(); + writeln!( + &mut code, + " public static func decodeBSATN(from reader: inout BSATNReader) throws -> {} {{", + type_name + ) + .unwrap(); + writeln!(&mut code, " return {}(", type_name).unwrap(); + for (i, (name, ty)) in product.elements.iter().enumerate() { + let field_name = name.deref().to_case(Case::Camel); + let decode_expr = get_swift_decode_expr(module, ty, "reader"); + if i + 1 < product.elements.len() { + writeln!(&mut code, " {}: {},", field_name, decode_expr).unwrap(); + } else { + writeln!(&mut code, " {}: {}", field_name, decode_expr).unwrap(); + } + } + writeln!(&mut code, " )").unwrap(); + writeln!(&mut code, " }}").unwrap(); + + writeln!(&mut code, "").unwrap(); + writeln!( + &mut code, + " public func encodeBSATN(to storage: inout BSATNStorage) throws {{" + ) + .unwrap(); + for (name, ty) in &product.elements { + let field_name = name.deref().to_case(Case::Camel); + let encode_stmt = get_swift_encode_stmt(module, ty, &format!("self.{}", field_name), "storage"); + writeln!(&mut code, " {}", encode_stmt).unwrap(); + } + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, "}}").unwrap(); + } + AlgebraicTypeDef::Sum(sum) => { + writeln!( + &mut code, + "public enum {}: Codable, Sendable, BSATNSpecialDecodable, BSATNSpecialEncodable {{", + type_name + ) + .unwrap(); + for (name, ty) in sum.variants.iter() { + let case_name = name.deref().to_case(Case::Camel); + if matches!(ty, AlgebraicTypeUse::Unit) { + writeln!(&mut code, " case {}", case_name).unwrap(); + } else { + let swift_ty = get_swift_type_use(module, ty); + writeln!(&mut code, " case {}({})", case_name, swift_ty).unwrap(); + } + } + + writeln!(&mut code, "").unwrap(); + writeln!( + &mut code, + " public static func decodeBSATN(from reader: inout BSATNReader) throws -> {} {{", + type_name + ) + .unwrap(); + writeln!(&mut code, " let tag = try reader.readU8()").unwrap(); + writeln!(&mut code, " switch tag {{").unwrap(); + for (idx, (name, ty)) in sum.variants.iter().enumerate() { + let case_name = name.deref().to_case(Case::Camel); + writeln!(&mut code, " case UInt8({idx}):").unwrap(); + if matches!(ty, AlgebraicTypeUse::Unit) { + writeln!(&mut code, " return .{}", case_name).unwrap(); + } else { + let decode_expr = get_swift_decode_expr(module, ty, "reader"); + writeln!(&mut code, " return .{}({})", case_name, decode_expr).unwrap(); + } + } + writeln!(&mut code, " default:").unwrap(); + writeln!(&mut code, " throw BSATNDecodingError.invalidType").unwrap(); + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, " }}").unwrap(); + + writeln!(&mut code, "").unwrap(); + writeln!( + &mut code, + " public func encodeBSATN(to storage: inout BSATNStorage) throws {{" + ) + .unwrap(); + writeln!(&mut code, " switch self {{").unwrap(); + for (idx, (name, ty)) in sum.variants.iter().enumerate() { + let case_name = name.deref().to_case(Case::Camel); + if matches!(ty, AlgebraicTypeUse::Unit) { + writeln!(&mut code, " case .{}:", case_name).unwrap(); + writeln!(&mut code, " storage.appendU8(UInt8({idx}))").unwrap(); + } else { + writeln!(&mut code, " case .{}(let value):", case_name).unwrap(); + writeln!(&mut code, " storage.appendU8(UInt8({idx}))").unwrap(); + let encode_stmt = get_swift_encode_stmt(module, ty, "value", "storage"); + writeln!(&mut code, " {}", encode_stmt).unwrap(); + } + } + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, " }}").unwrap(); + + writeln!(&mut code, "").unwrap(); + writeln!(&mut code, " public init(from decoder: Decoder) throws {{").unwrap(); + writeln!(&mut code, " var container = try decoder.singleValueContainer()").unwrap(); + writeln!(&mut code, " let tag = try container.decode(UInt8.self)").unwrap(); + writeln!(&mut code, " switch tag {{").unwrap(); + for (idx, (name, ty)) in sum.variants.iter().enumerate() { + let case_name = name.deref().to_case(Case::Camel); + writeln!(&mut code, " case UInt8({idx}):").unwrap(); + if matches!(ty, AlgebraicTypeUse::Unit) { + writeln!(&mut code, " self = .{}", case_name).unwrap(); + } else { + let swift_ty = get_swift_type_use(module, ty); + writeln!( + &mut code, + " self = .{}(try container.decode({}.self))", + case_name, swift_ty + ) + .unwrap(); + } + } + writeln!(&mut code, " default:").unwrap(); + writeln!(&mut code, " throw BSATNDecodingError.invalidType").unwrap(); + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, " }}").unwrap(); + + writeln!(&mut code, "").unwrap(); + writeln!(&mut code, " public func encode(to encoder: Encoder) throws {{").unwrap(); + writeln!(&mut code, " var container = encoder.singleValueContainer()").unwrap(); + writeln!(&mut code, " switch self {{").unwrap(); + for (idx, (name, ty)) in sum.variants.iter().enumerate() { + let case_name = name.deref().to_case(Case::Camel); + if matches!(ty, AlgebraicTypeUse::Unit) { + writeln!(&mut code, " case .{}:", case_name).unwrap(); + writeln!(&mut code, " try container.encode(UInt8({idx}))").unwrap(); + } else { + writeln!(&mut code, " case .{}(let value):", case_name).unwrap(); + writeln!(&mut code, " try container.encode(UInt8({idx}))").unwrap(); + writeln!(&mut code, " try container.encode(value)").unwrap(); + } + } + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, "}}").unwrap(); + } + AlgebraicTypeDef::PlainEnum(plain_enum) => { + writeln!( + &mut code, + "public enum {}: UInt8, Codable, Sendable, BSATNSpecialDecodable, BSATNSpecialEncodable {{", + type_name + ) + .unwrap(); + for (idx, name) in plain_enum.variants.iter().enumerate() { + let case_name = name.deref().to_case(Case::Camel); + writeln!(&mut code, " case {} = {}", case_name, idx).unwrap(); + } + + writeln!(&mut code, "").unwrap(); + writeln!( + &mut code, + " public static func decodeBSATN(from reader: inout BSATNReader) throws -> {} {{", + type_name + ) + .unwrap(); + writeln!(&mut code, " let tag = try reader.readU8()").unwrap(); + writeln!(&mut code, " guard let value = Self(rawValue: tag) else {{").unwrap(); + writeln!(&mut code, " throw BSATNDecodingError.invalidType").unwrap(); + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, " return value").unwrap(); + writeln!(&mut code, " }}").unwrap(); + + writeln!(&mut code, "").unwrap(); + writeln!( + &mut code, + " public func encodeBSATN(to storage: inout BSATNStorage) throws {{" + ) + .unwrap(); + writeln!(&mut code, " storage.appendU8(self.rawValue)").unwrap(); + writeln!(&mut code, " }}").unwrap(); + + writeln!(&mut code, "").unwrap(); + writeln!(&mut code, " public init(from decoder: Decoder) throws {{").unwrap(); + writeln!(&mut code, " let container = try decoder.singleValueContainer()").unwrap(); + writeln!(&mut code, " let tag = try container.decode(UInt8.self)").unwrap(); + writeln!(&mut code, " guard let value = Self(rawValue: tag) else {{").unwrap(); + writeln!(&mut code, " throw BSATNDecodingError.invalidType").unwrap(); + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, " self = value").unwrap(); + writeln!(&mut code, " }}").unwrap(); + + writeln!(&mut code, "").unwrap(); + writeln!(&mut code, " public func encode(to encoder: Encoder) throws {{").unwrap(); + writeln!(&mut code, " var container = encoder.singleValueContainer()").unwrap(); + writeln!(&mut code, " try container.encode(self.rawValue)").unwrap(); + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, "}}").unwrap(); + } + } + + vec![OutputFile { + filename: format!("{}.swift", type_name), + code, + }] + } + + fn generate_reducer_file(&self, module: &ModuleDef, reducer: &ReducerDef) -> OutputFile { + let reducer_name = reducer.name.deref(); + let reducer_name_pascal = reducer.name.deref().to_case(Case::Pascal); + + let mut code = String::new(); + write_generated_file_preamble(&mut code); + writeln!(&mut code, "import Foundation").unwrap(); + writeln!(&mut code, "import simd").unwrap(); + writeln!(&mut code, "import SpacetimeDB\n").unwrap(); + + writeln!(&mut code, "public enum {} {{", reducer_name_pascal).unwrap(); + + // Write the internal args struct used for BSATN encoding + writeln!( + &mut code, + " public struct _Args: Codable, Sendable, BSATNSpecialEncodable {{" + ) + .unwrap(); + for (name, ty) in &reducer.params_for_generate.elements { + let field_name = name.deref().to_case(Case::Camel); + let swift_ty = get_swift_type_use(module, ty); + writeln!(&mut code, " public var {}: {}", field_name, swift_ty).unwrap(); + } + writeln!(&mut code, "").unwrap(); + writeln!( + &mut code, + " public func encodeBSATN(to storage: inout BSATNStorage) throws {{" + ) + .unwrap(); + for (name, ty) in &reducer.params_for_generate.elements { + let field_name = name.deref().to_case(Case::Camel); + let encode_stmt = get_swift_encode_stmt(module, ty, &format!("self.{}", field_name), "storage"); + writeln!(&mut code, " {}", encode_stmt).unwrap(); + } + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, " }}\n").unwrap(); + + // Write a helper struct for invoking the reducer + write!(&mut code, " @MainActor public static func invoke(").unwrap(); + + let mut first = true; + for (name, ty) in &reducer.params_for_generate.elements { + if !first { + write!(&mut code, ", ").unwrap(); + } + first = false; + let field_name = name.deref().to_case(Case::Camel); + let swift_ty = get_swift_type_use(module, ty); + write!(&mut code, "{}: {}", field_name, swift_ty).unwrap(); + } + writeln!(&mut code, ") {{").unwrap(); + + // Build the argument struct to encode + writeln!(&mut code, " let args = _Args(").unwrap(); + first = true; + for (name, _ty) in &reducer.params_for_generate.elements { + if !first { + writeln!(&mut code, ",").unwrap(); + } + first = false; + let field_name = name.deref().to_case(Case::Camel); + write!(&mut code, " {}: {}", field_name, field_name).unwrap(); + } + if !reducer.params_for_generate.elements.is_empty() { + writeln!(&mut code, "").unwrap(); + } + writeln!(&mut code, " )").unwrap(); + + // Encode and send + writeln!(&mut code, " do {{").unwrap(); + writeln!(&mut code, " let argBytes = try BSATNEncoder().encode(args)").unwrap(); + writeln!( + &mut code, + " SpacetimeClient.shared?.send(\"{}\", argBytes)", + reducer_name + ) + .unwrap(); + writeln!(&mut code, " }} catch {{").unwrap(); + writeln!( + &mut code, + " print(\"Failed to encode {} arguments: \\(error)\")", + reducer_name_pascal + ) + .unwrap(); + writeln!(&mut code, " }}").unwrap(); + + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, "}}").unwrap(); + + OutputFile { + filename: format!("{}.swift", reducer_name_pascal), + code, + } + } + + fn generate_procedure_file(&self, module: &ModuleDef, procedure: &ProcedureDef) -> OutputFile { + let procedure_name = procedure.name.deref(); + let procedure_name_pascal = procedure.name.deref().to_case(Case::Pascal); + let return_swift_ty = get_swift_type_use(module, &procedure.return_type_for_generate); + let return_is_unit = matches!(&procedure.return_type_for_generate, AlgebraicTypeUse::Unit); + let callback_return_ty = if return_is_unit { + "Void".to_string() + } else { + return_swift_ty.clone() + }; + + let mut code = String::new(); + write_generated_file_preamble(&mut code); + writeln!(&mut code, "import Foundation").unwrap(); + writeln!(&mut code, "import simd").unwrap(); + writeln!(&mut code, "import SpacetimeDB\n").unwrap(); + + writeln!(&mut code, "public enum {}Procedure {{", procedure_name_pascal).unwrap(); + + writeln!( + &mut code, + " public struct _Args: Codable, Sendable, BSATNSpecialEncodable {{" + ) + .unwrap(); + for (name, ty) in &procedure.params_for_generate.elements { + let field_name = name.deref().to_case(Case::Camel); + let swift_ty = get_swift_type_use(module, ty); + writeln!(&mut code, " public var {}: {}", field_name, swift_ty).unwrap(); + } + writeln!(&mut code, "").unwrap(); + writeln!( + &mut code, + " public func encodeBSATN(to storage: inout BSATNStorage) throws {{" + ) + .unwrap(); + for (name, ty) in &procedure.params_for_generate.elements { + let field_name = name.deref().to_case(Case::Camel); + let encode_stmt = get_swift_encode_stmt(module, ty, &format!("self.{}", field_name), "storage"); + writeln!(&mut code, " {}", encode_stmt).unwrap(); + } + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, " }}\n").unwrap(); + + write!(&mut code, " @MainActor public static func invoke(").unwrap(); + let mut first = true; + for (name, ty) in &procedure.params_for_generate.elements { + if !first { + write!(&mut code, ", ").unwrap(); + } + first = false; + let field_name = name.deref().to_case(Case::Camel); + let swift_ty = get_swift_type_use(module, ty); + write!(&mut code, "{}: {}", field_name, swift_ty).unwrap(); + } + if !procedure.params_for_generate.elements.is_empty() { + write!(&mut code, ", ").unwrap(); + } + write!( + &mut code, + "callback: ((Result<{}, Error>) -> Void)? = nil", + callback_return_ty + ) + .unwrap(); + writeln!(&mut code, ") {{").unwrap(); + + writeln!(&mut code, " let args = _Args(").unwrap(); + first = true; + for (name, _ty) in &procedure.params_for_generate.elements { + if !first { + writeln!(&mut code, ",").unwrap(); + } + first = false; + let field_name = name.deref().to_case(Case::Camel); + write!(&mut code, " {}: {}", field_name, field_name).unwrap(); + } + if !procedure.params_for_generate.elements.is_empty() { + writeln!(&mut code, "").unwrap(); + } + writeln!(&mut code, " )").unwrap(); + + writeln!(&mut code, " do {{").unwrap(); + writeln!(&mut code, " let argBytes = try BSATNEncoder().encode(args)").unwrap(); + writeln!(&mut code, " if let callback {{").unwrap(); + if return_is_unit { + writeln!( + &mut code, + " SpacetimeClient.shared?.sendProcedure(\"{}\", argBytes, decodeReturn: {{ _ in () }}, completion: callback)", + procedure_name + ) + .unwrap(); + } else { + writeln!( + &mut code, + " SpacetimeClient.shared?.sendProcedure(\"{}\", argBytes, responseType: {}.self, completion: callback)", + procedure_name, return_swift_ty + ) + .unwrap(); + } + writeln!(&mut code, " }} else {{").unwrap(); + writeln!( + &mut code, + " SpacetimeClient.shared?.sendProcedure(\"{}\", argBytes)", + procedure_name + ) + .unwrap(); + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, " }} catch {{").unwrap(); + writeln!( + &mut code, + " print(\"Failed to encode {}Procedure arguments: \\(error)\")", + procedure_name_pascal + ) + .unwrap(); + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, "}}").unwrap(); + + OutputFile { + filename: format!("{}Procedure.swift", procedure.name.deref().to_case(Case::Pascal)), + code, + } + } + + fn generate_global_files(&self, module: &ModuleDef, options: &CodegenOptions) -> Vec { + let mut code = String::new(); + write_generated_file_preamble(&mut code); + writeln!(&mut code, "import Foundation").unwrap(); + writeln!(&mut code, "import simd").unwrap(); + writeln!(&mut code, "import SpacetimeDB\n").unwrap(); + + writeln!(&mut code, "public enum SpacetimeModule {{").unwrap(); + writeln!(&mut code, " @MainActor public static func registerTables() {{").unwrap(); + // Register all tables + for (table_name, _accessor_name, product_type_ref) in iter_table_names_and_types(module, options.visibility) { + let row_type = type_ref_name(module, product_type_ref); + writeln!( + &mut code, + " SpacetimeClient.clientCache.registerTable(tableName: \"{}\", rowType: {}.self)", + table_name.deref(), + row_type + ) + .unwrap(); + } + writeln!(&mut code, " }}").unwrap(); + writeln!(&mut code, "}}").unwrap(); + + vec![OutputFile { + filename: "SpacetimeModule.swift".to_string(), + code, + }] + } +} diff --git a/crates/codegen/tests/codegen.rs b/crates/codegen/tests/codegen.rs index 06dc3ebe8fc..f1cc9627dcf 100644 --- a/crates/codegen/tests/codegen.rs +++ b/crates/codegen/tests/codegen.rs @@ -1,4 +1,4 @@ -use spacetimedb_codegen::{generate, CodegenOptions, Csharp, Rust, TypeScript}; +use spacetimedb_codegen::{generate, CodegenOptions, Csharp, Rust, Swift, TypeScript, AUTO_GENERATED_PREFIX}; use spacetimedb_data_structures::map::HashMap; use spacetimedb_schema::def::ModuleDef; use spacetimedb_testing::modules::{CompilationMode, CompiledModule}; @@ -39,3 +39,21 @@ declare_tests! { test_codegen_typescript => TypeScript, test_codegen_rust => Rust, } + +#[test] +fn swift_codegen_files_start_with_autogenerated_prefix() { + let module = compiled_module(); + let outfiles = generate(module, &Swift, &CodegenOptions::default()); + assert!( + !outfiles.is_empty(), + "Swift codegen should emit at least one output file" + ); + + for output in outfiles { + assert!( + output.code.starts_with(AUTO_GENERATED_PREFIX), + "Swift output '{}' must start with autogenerated prefix", + output.filename + ); + } +} diff --git a/demo/ninja-game/.gitignore b/demo/ninja-game/.gitignore new file mode 100644 index 00000000000..fc7a372eedf --- /dev/null +++ b/demo/ninja-game/.gitignore @@ -0,0 +1,13 @@ +.DS_Store +**/.DS_Store + +# Swift build artifacts +**/.build/ +**/.swiftpm/ + +# Rust/Cargo build artifacts +**/target/ + +# Python cache +**/__pycache__/ +*.pyc diff --git a/demo/ninja-game/MIRRORING.md b/demo/ninja-game/MIRRORING.md new file mode 100644 index 00000000000..1ef0e7097b3 --- /dev/null +++ b/demo/ninja-game/MIRRORING.md @@ -0,0 +1,16 @@ +# Mirror Notes + +This repository mirrors `demo/ninja-game` from the SpacetimeDB monorepo. + +Upstream source: +- https://github.com/avias8/SpacetimeDB +- Path: `demo/ninja-game` + +Client dependency: +- Swift SDK package: `https://github.com/avias8/spacetimedb-swift.git` +- Version: `from: 0.21.0` + +Suggested sync workflow: +1. Copy latest `demo/ninja-game` from upstream. +2. Keep `client-swift/Package.swift` pointing to the mirrored Swift SDK URL. +3. Run `cd client-swift && swift build && swift test` (if tests exist). diff --git a/demo/ninja-game/README.md b/demo/ninja-game/README.md new file mode 100644 index 00000000000..0d6711d75ad --- /dev/null +++ b/demo/ninja-game/README.md @@ -0,0 +1,45 @@ +# Ninja Game (Swift + SpacetimeDB) + +Realtime 2D multiplayer demo used to validate the Swift SDK at game-like update rates. + +## Local Setup + +From repo root: + +```bash +spacetime start +spacetime publish -s local -p demo/ninja-game/spacetimedb ninjagame -c -y +``` + +Run the client: + +```bash +cd demo/ninja-game/client-swift +open Package.swift +``` + +In Xcode, run `NinjaGameClient`. + +## Multi-Client Soak Check (Milestone 6) + +1. Launch two client instances (or run once in Xcode and once with `swift run`). +2. Join both clients with different names. +3. Move on both clients for 2-3 minutes. +4. Trigger combat, pickups, respawn, and clear-server flow. + +Expected signals: + +- Status bar remains `Connected`. +- Player count and positions replicate across both clients in near real time. +- Weapon drops and pickups replicate consistently. +- Combat health/kills updates match across clients. +- No repeated reducer failures in logs. + +## Troubleshooting + +- `missing reducer ... publish ninjagame module` in status bar: + module on server is stale; re-run publish command and reconnect. +- `Disconnected` or websocket errors: + confirm local server is running on `http://127.0.0.1:3000`. +- No replicated updates: + verify `ninjagame` database name and that the module publish succeeded. diff --git a/demo/ninja-game/SOAK_NOTES_2026-02-28.md b/demo/ninja-game/SOAK_NOTES_2026-02-28.md new file mode 100644 index 00000000000..64646c3dab9 --- /dev/null +++ b/demo/ninja-game/SOAK_NOTES_2026-02-28.md @@ -0,0 +1,75 @@ +# NinjaGame 2-Client Soak Notes (2026-02-28) + +## Scope + +Runbook-aligned local 2-client soak on `ninjagame` after republish, measuring replication cadence and desync indicators. + +## Setup Commands + +```bash +spacetime publish -s local -p demo/ninja-game/spacetimedb ninjagame -c -y +``` + +Server ping during run: + +```bash +curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/v1/ping +# 200 +``` + +## Soak Method + +- Two concurrent Swift SDK clients (`SoakA`, `SoakB`) +- Duration: 120 seconds each +- Movement: deterministic circular path at 20Hz reducer sends (`move_player`) +- Join flow: both clients call `join` at connect +- Metrics captured per client: + - peer row sample count + - average / max peer update gap (ms) + - max observed peer positional jump + - reducer error count + +## Raw Results (Movement-Only) + +`SoakA`: + +```json +{"elapsedSeconds":132.91749596595764,"sawPeer":true,"reducerErrors":[],"peerSamples":4787,"connected":true,"peerGapMaxMs":92.12613105773926,"disconnectError":"","peerGapAvgMs":27.314124324557874,"name":"SoakA","durationSeconds":120,"reducerErrorCount":0,"peerMaxJump":180,"disconnected":true} +``` + +`SoakB`: + +```json +{"elapsedSeconds":132.8305640220642,"sawPeer":true,"peerGapMaxMs":92.12613105773926,"reducerErrorCount":0,"disconnectError":"","peerSamples":4788,"durationSeconds":120,"disconnected":true,"name":"SoakB","peerGapAvgMs":27.318672668667407,"peerMaxJump":7.199581623077393,"reducerErrors":[],"connected":true} +``` + +## Raw Results (Combat-Mixed Traffic) + +Combat-mixed variant includes `attack` and `spawn_weapon` traffic while moving. + +`CombatA`: + +```json +{"peerMaxJump":190,"reducerErrorCount":0,"name":"CombatA","sawPeer":true,"disconnectError":"","disconnected":true,"peerSamples":11158,"elapsedSeconds":101.23913788795471,"connected":true,"peerGapMaxMs":1498.4707832336426,"reducerErrors":[],"durationSeconds":90,"peerGapAvgMs":27.046896811826837} +``` + +`CombatB`: + +```json +{"peerGapAvgMs":26.886929714531068,"durationSeconds":90,"elapsedSeconds":101.32108783721924,"disconnected":true,"peerGapMaxMs":1498.4748363494873,"name":"CombatB","reducerErrorCount":0,"reducerErrors":[],"sawPeer":true,"peerSamples":11150,"disconnectError":"","peerMaxJump":7.1246495246887207,"connected":true} +``` + +## Observed Latency / Desync Notes + +- Both clients connected, saw each other, and completed the full soak without reducer failures. +- Replication cadence stayed stable: + - average peer update gap ~27.3ms + - max peer update gap ~92.1ms +- No sustained desync pattern observed in metrics. +- `SoakA` max jump of `180` is consistent with an initial peer-acquisition snap (first non-zero peer position sample). `SoakB` steady-state max jump was `7.2`, indicating smooth tracking after initial convergence. +- Combat-mixed run also had zero reducer failures. +- Combat-mixed max peer-gap spikes (~1.5s) appeared during transient gameplay state stalls (e.g., target not producing movement updates), not as repeated transport drops; average gap remained ~27ms. + +## Conclusion + +Protocol-level 2-client soak shows stable local replication and no recurring latency/desync regressions for continuous movement and combat-mixed traffic in local mode. diff --git a/demo/ninja-game/client-swift/.gitignore b/demo/ninja-game/client-swift/.gitignore new file mode 100644 index 00000000000..0023a534063 --- /dev/null +++ b/demo/ninja-game/client-swift/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/demo/ninja-game/client-swift/Package.resolved b/demo/ninja-game/client-swift/Package.resolved new file mode 100644 index 00000000000..d28cf091099 --- /dev/null +++ b/demo/ninja-game/client-swift/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "6264ec4ccf044d78b88a3d9056cca0cdcdfb68d0f89078f08b0f6eced3c8fa79", + "pins" : [ + { + "identity" : "spacetimedb-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/avias8/spacetimedb-swift.git", + "state" : { + "revision" : "96a6a9e01824bd01d666e35add2dbc93f45fdf86", + "version" : "0.21.0" + } + } + ], + "version" : 3 +} diff --git a/demo/ninja-game/client-swift/Package.swift b/demo/ninja-game/client-swift/Package.swift new file mode 100644 index 00000000000..e1e789eb293 --- /dev/null +++ b/demo/ninja-game/client-swift/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "NinjaGameClient", + platforms: [ + .macOS(.v15), + .iOS(.v17) + ], + products: [ + .executable( + name: "NinjaGameClient", + targets: ["NinjaGameClient"] + ) + ], + dependencies: [ + .package(url: "https://github.com/avias8/spacetimedb-swift.git", from: "0.21.0") + ], + targets: [ + .executableTarget( + name: "NinjaGameClient", + dependencies: [ + .product(name: "SpacetimeDB", package: "spacetimedb-swift") + ], + resources: [ + .process("Resources") + ] + ) + ] +) diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Attack.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Attack.swift new file mode 100644 index 00000000000..d80af7dc502 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Attack.swift @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum Attack { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + public var targetId: UInt64 + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.appendU64(self.targetId) + } + } + + @MainActor public static func invoke(targetId: UInt64) { + let args = _Args( + targetId: targetId + ) + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("attack", argBytes) + } catch { + print("Failed to encode Attack arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/BotPlayer.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/BotPlayer.swift new file mode 100644 index 00000000000..4bb75628c2b --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/BotPlayer.swift @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public struct BotPlayer: Codable, Sendable, BSATNSpecialDecodable, BSATNSpecialEncodable { + public var id: UInt64 + public var lobbyId: UInt64 + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> BotPlayer { + return BotPlayer( + id: try reader.readU64(), + lobbyId: try reader.readU64() + ) + } + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.appendU64(self.id) + storage.appendU64(self.lobbyId) + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/ClearServer.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/ClearServer.swift new file mode 100644 index 00000000000..83768293d2b --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/ClearServer.swift @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum ClearServer { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + } + } + + @MainActor public static func invoke() { + let args = _Args() + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("clear_server", argBytes) + } catch { + print("Failed to encode ClearServer arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/CombatHitCooldown.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/CombatHitCooldown.swift new file mode 100644 index 00000000000..d08cad901f3 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/CombatHitCooldown.swift @@ -0,0 +1,29 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public struct CombatHitCooldown: Codable, Sendable, BSATNSpecialDecodable, BSATNSpecialEncodable { + public var id: UInt64 + public var attackerId: UInt64 + public var targetId: UInt64 + public var lastHitMicros: Int64 + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> CombatHitCooldown { + return CombatHitCooldown( + id: try reader.readU64(), + attackerId: try reader.readU64(), + targetId: try reader.readU64(), + lastHitMicros: try reader.readI64() + ) + } + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.appendU64(self.id) + storage.appendU64(self.attackerId) + storage.appendU64(self.targetId) + storage.appendI64(self.lastHitMicros) + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/CreateLobby.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/CreateLobby.swift new file mode 100644 index 00000000000..3cb620efc68 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/CreateLobby.swift @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum CreateLobby { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + public var name: String + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + try storage.appendString(self.name) + } + } + + @MainActor public static func invoke(name: String) { + let args = _Args( + name: name + ) + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("create_lobby", argBytes) + } catch { + print("Failed to encode CreateLobby arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/EndMatch.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/EndMatch.swift new file mode 100644 index 00000000000..7c6ffa034f1 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/EndMatch.swift @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum EndMatch { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + } + } + + @MainActor public static func invoke() { + let args = _Args() + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("end_match", argBytes) + } catch { + print("Failed to encode EndMatch arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Join.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Join.swift new file mode 100644 index 00000000000..110d61868ce --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Join.swift @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum Join { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + public var name: String + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + try storage.appendString(self.name) + } + } + + @MainActor public static func invoke(name: String) { + let args = _Args( + name: name + ) + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("join", argBytes) + } catch { + print("Failed to encode Join arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/JoinLobby.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/JoinLobby.swift new file mode 100644 index 00000000000..08d227afa35 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/JoinLobby.swift @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum JoinLobby { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + public var lobbyId: UInt64 + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.appendU64(self.lobbyId) + } + } + + @MainActor public static func invoke(lobbyId: UInt64) { + let args = _Args( + lobbyId: lobbyId + ) + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("join_lobby", argBytes) + } catch { + print("Failed to encode JoinLobby arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Leave.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Leave.swift new file mode 100644 index 00000000000..0888c2f40ac --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Leave.swift @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum Leave { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + } + } + + @MainActor public static func invoke() { + let args = _Args() + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("leave", argBytes) + } catch { + print("Failed to encode Leave arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/LeaveLobby.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/LeaveLobby.swift new file mode 100644 index 00000000000..f8de97a857f --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/LeaveLobby.swift @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum LeaveLobby { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + } + } + + @MainActor public static func invoke() { + let args = _Args() + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("leave_lobby", argBytes) + } catch { + print("Failed to encode LeaveLobby arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Lobby.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Lobby.swift new file mode 100644 index 00000000000..b0587a87635 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Lobby.swift @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public struct Lobby: Codable, Sendable, BSATNSpecialDecodable, BSATNSpecialEncodable { + public var id: UInt64 + public var name: String + public var isPlaying: Bool + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> Lobby { + return Lobby( + id: try reader.readU64(), + name: try reader.readString(), + isPlaying: try reader.readBool() + ) + } + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.appendU64(self.id) + try storage.appendString(self.name) + storage.appendBool(self.isPlaying) + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/LobbyTable.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/LobbyTable.swift new file mode 100644 index 00000000000..1615fa48a42 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/LobbyTable.swift @@ -0,0 +1,14 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public struct LobbyTable { + @MainActor public static var cache: TableCache { + return SpacetimeClient.clientCache.getTableCache(tableName: "lobby") + } +} + +extension Lobby: Identifiable {} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/MovePlayer.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/MovePlayer.swift new file mode 100644 index 00000000000..e18684e3457 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/MovePlayer.swift @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum MovePlayer { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + public var x: Float + public var y: Float + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.appendFloat(self.x) + storage.appendFloat(self.y) + } + } + + @MainActor public static func invoke(x: Float, y: Float) { + let args = _Args( + x: x, + y: y + ) + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("move_player", argBytes) + } catch { + print("Failed to encode MovePlayer arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Player.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Player.swift new file mode 100644 index 00000000000..2dd87ba354c --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Player.swift @@ -0,0 +1,47 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public struct Player: Codable, Sendable, BSATNSpecialDecodable, BSATNSpecialEncodable { + public var id: UInt64 + public var name: String + public var x: Float + public var y: Float + public var health: UInt32 + public var weaponCount: UInt32 + public var kills: UInt32 + public var respawnAtMicros: Int64 + public var isReady: Bool + public var lobbyId: UInt64? + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> Player { + return Player( + id: try reader.readU64(), + name: try reader.readString(), + x: try reader.readFloat(), + y: try reader.readFloat(), + health: try reader.readU32(), + weaponCount: try reader.readU32(), + kills: try reader.readU32(), + respawnAtMicros: try reader.readI64(), + isReady: try reader.readBool(), + lobbyId: try UInt64?.decodeBSATN(from: &reader) + ) + } + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.appendU64(self.id) + try storage.appendString(self.name) + storage.appendFloat(self.x) + storage.appendFloat(self.y) + storage.appendU32(self.health) + storage.appendU32(self.weaponCount) + storage.appendU32(self.kills) + storage.appendI64(self.respawnAtMicros) + storage.appendBool(self.isReady) + try self.lobbyId.encodeBSATN(to: &storage) + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/PlayerTable.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/PlayerTable.swift new file mode 100644 index 00000000000..3f37740cd41 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/PlayerTable.swift @@ -0,0 +1,14 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public struct PlayerTable { + @MainActor public static var cache: TableCache { + return SpacetimeClient.clientCache.getTableCache(tableName: "player") + } +} + +extension Player: Identifiable {} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Respawn.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Respawn.swift new file mode 100644 index 00000000000..d34d75ce14d --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/Respawn.swift @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum Respawn { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + } + } + + @MainActor public static func invoke() { + let args = _Args() + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("respawn", argBytes) + } catch { + print("Failed to encode Respawn arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/SetName.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/SetName.swift new file mode 100644 index 00000000000..ca860cc6c1b --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/SetName.swift @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum SetName { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + public var name: String + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + try storage.appendString(self.name) + } + } + + @MainActor public static func invoke(name: String) { + let args = _Args( + name: name + ) + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("set_name", argBytes) + } catch { + print("Failed to encode SetName arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/SpacetimeModule.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/SpacetimeModule.swift new file mode 100644 index 00000000000..eef55b3913e --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/SpacetimeModule.swift @@ -0,0 +1,14 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum SpacetimeModule { + @MainActor public static func registerTables() { + SpacetimeClient.clientCache.registerTable(tableName: "lobby", rowType: Lobby.self) + SpacetimeClient.clientCache.registerTable(tableName: "player", rowType: Player.self) + SpacetimeClient.clientCache.registerTable(tableName: "weapon_drop", rowType: WeaponDrop.self) + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/SpawnTestPlayer.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/SpawnTestPlayer.swift new file mode 100644 index 00000000000..19670f7c123 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/SpawnTestPlayer.swift @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum SpawnTestPlayer { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + } + } + + @MainActor public static func invoke() { + let args = _Args() + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("spawn_test_player", argBytes) + } catch { + print("Failed to encode SpawnTestPlayer arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/SpawnWeapon.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/SpawnWeapon.swift new file mode 100644 index 00000000000..44d39af4db7 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/SpawnWeapon.swift @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum SpawnWeapon { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + public var x: Float + public var y: Float + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.appendFloat(self.x) + storage.appendFloat(self.y) + } + } + + @MainActor public static func invoke(x: Float, y: Float) { + let args = _Args( + x: x, + y: y + ) + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("spawn_weapon", argBytes) + } catch { + print("Failed to encode SpawnWeapon arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/StartMatch.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/StartMatch.swift new file mode 100644 index 00000000000..fe3a6e16c00 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/StartMatch.swift @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum StartMatch { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + } + } + + @MainActor public static func invoke() { + let args = _Args() + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("start_match", argBytes) + } catch { + print("Failed to encode StartMatch arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/ToggleReady.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/ToggleReady.swift new file mode 100644 index 00000000000..5f17211ca25 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/ToggleReady.swift @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public enum ToggleReady { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + } + } + + @MainActor public static func invoke() { + let args = _Args() + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("toggle_ready", argBytes) + } catch { + print("Failed to encode ToggleReady arguments: \(error)") + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/WeaponDrop.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/WeaponDrop.swift new file mode 100644 index 00000000000..2f0d0379762 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/WeaponDrop.swift @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public struct WeaponDrop: Codable, Sendable, BSATNSpecialDecodable, BSATNSpecialEncodable { + public var id: UInt64 + public var x: Float + public var y: Float + public var damage: UInt32 + public var lobbyId: UInt64 + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> WeaponDrop { + return WeaponDrop( + id: try reader.readU64(), + x: try reader.readFloat(), + y: try reader.readFloat(), + damage: try reader.readU32(), + lobbyId: try reader.readU64() + ) + } + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.appendU64(self.id) + storage.appendFloat(self.x) + storage.appendFloat(self.y) + storage.appendU32(self.damage) + storage.appendU64(self.lobbyId) + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/WeaponDropTable.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/WeaponDropTable.swift new file mode 100644 index 00000000000..2c7cccb1ab0 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated/WeaponDropTable.swift @@ -0,0 +1,14 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB +import simd + +public struct WeaponDropTable { + @MainActor public static var cache: TableCache { + return SpacetimeClient.clientCache.getTableCache(tableName: "weapon_drop") + } +} + +extension WeaponDrop: Identifiable {} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameApp.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameApp.swift new file mode 100644 index 00000000000..5f8375256fa --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameApp.swift @@ -0,0 +1,1906 @@ +import SwiftUI +import SpacetimeDB +import Observation +@preconcurrency import AVFoundation +#if canImport(AppKit) +import AppKit +#endif + +enum SurvivorsTheme { + static let accent = Color(red: 0.28, green: 0.58, blue: 0.98) + static let panelStroke = Color.white.opacity(0.16) + static let backdropTop = Color(red: 0.06, green: 0.08, blue: 0.16) + static let backdropBottom = Color(red: 0.10, green: 0.12, blue: 0.23) + static let backdropGlow = Color(red: 0.24, green: 0.45, blue: 0.95).opacity(0.26) + static let backdropGlowSecondary = Color(red: 0.10, green: 0.72, blue: 0.92).opacity(0.16) +} + +// MARK: - Shared UI Style + +extension View { + func pixelPanel() -> some View { + background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(SurvivorsTheme.panelStroke, lineWidth: 1) + ) + ) + } +} + +struct PixelButtonStyle: ButtonStyle { + var filled: Bool = false + var danger: Bool = false + var accentColor: Color = SurvivorsTheme.accent + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .foregroundStyle(fgColor) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(bgColor(configuration.isPressed)) + } + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(borderColor, lineWidth: 1) + } + .shadow(color: Color.black.opacity(configuration.isPressed ? 0.04 : 0.10), radius: 4, x: 0, y: 1) + .scaleEffect(configuration.isPressed ? 0.985 : 1.0) + .animation(.easeInOut(duration: 0.10), value: configuration.isPressed) + } + + private var fgColor: Color { + if danger && filled { + return .white + } + if danger { + return Color(red: 1.0, green: 0.42, blue: 0.42) + } + return filled ? .white : .primary + } + + private func bgColor(_ pressed: Bool) -> Color { + if danger && filled { + return Color(red: 0.85, green: 0.20, blue: 0.22).opacity(pressed ? 0.82 : 0.96) + } + if danger { + return Color(red: 0.85, green: 0.20, blue: 0.22).opacity(pressed ? 0.18 : 0.10) + } + if filled { + return accentColor.opacity(pressed ? 0.82 : 0.95) + } + return Color.white.opacity(pressed ? 0.16 : 0.10) + } + + private var borderColor: Color { + if danger { + return Color(red: 1.0, green: 0.42, blue: 0.42).opacity(0.75) + } + if filled { + return accentColor.opacity(0.86) + } + return Color.white.opacity(0.24) + } +} + +struct SurvivorsChipBackground: View { + var cornerRadius: CGFloat = 6 + + var body: some View { + SurvivorsShapeSurface( + shape: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous), + fallbackMaterial: .regularMaterial, + usesLiquidGlass: true + ) + } +} + +struct SurvivorsPanelBackground: View { + var cornerRadius: CGFloat = 18 + + var body: some View { + // Keep large panels deterministic to avoid liquid geometry morphing. + SurvivorsShapeSurface( + shape: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous), + fallbackMaterial: .ultraThinMaterial, + stroke: SurvivorsTheme.panelStroke, + lineWidth: 1, + usesLiquidGlass: false + ) + } +} + + + +struct SurvivorsShapeSurface: View { + let shape: S + var fallbackMaterial: Material + var stroke: Color? = nil + var lineWidth: CGFloat = 1 + var usesLiquidGlass: Bool = true + + var body: some View { + liquidOrFallback + .overlay { + if let stroke { + shape.strokeBorder(stroke, lineWidth: lineWidth) + } + } + } + + @ViewBuilder + private var liquidOrFallback: some View { + if usesLiquidGlass, #available(macOS 26.0, iOS 26.0, *) { + shape + .fill(.clear) + .glassEffect() + .clipShape(shape) + } else { + shape.fill(fallbackMaterial) + } + } +} + +struct SurvivorsBackdrop: View { + // Deterministic star field using golden-ratio hashing. + private static let stars: [(x: Double, y: Double, sz: CGFloat, spd: Double, ph: Double)] = (0..<60).map { i in + let g = 0.6180339887498949 + return ( + x: (Double(i) * g).truncatingRemainder(dividingBy: 1.0), + y: (Double(i * 7 + 3) * g).truncatingRemainder(dividingBy: 1.0), + sz: CGFloat(1.5 + (Double(i * 13 + 7) * g).truncatingRemainder(dividingBy: 1.0) * 2.0), + spd: 0.4 + (Double(i * 19 + 11) * g).truncatingRemainder(dividingBy: 1.0) * 1.4, + ph: (Double(i * 31 + 17) * g).truncatingRemainder(dividingBy: 1.0) * .pi * 2 + ) + } + + var body: some View { + TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { timeline in + let t = timeline.date.timeIntervalSinceReferenceDate + let glowAX = 0.50 + 0.28 * cos(t * 0.07) + let glowAY = 0.38 + 0.20 * sin(t * 0.05) + let glowBX = 0.52 + 0.24 * sin(t * 0.06 + 1.3) + let glowBY = 0.62 + 0.20 * cos(t * 0.05 + 0.8) + + ZStack { + LinearGradient( + colors: [SurvivorsTheme.backdropTop, SurvivorsTheme.backdropBottom], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + // Slow-scrolling pixel grid + Canvas { ctx, size in + let grid: CGFloat = 64 + let xShift = CGFloat((t * 4).truncatingRemainder(dividingBy: Double(grid))) + let yShift = CGFloat((t * 3).truncatingRemainder(dividingBy: Double(grid))) + var path = Path() + + var x = -grid + xShift + while x <= size.width + grid { + path.move(to: CGPoint(x: x, y: 0)) + path.addLine(to: CGPoint(x: x, y: size.height)) + x += grid + } + + var y = -grid + yShift + while y <= size.height + grid { + path.move(to: CGPoint(x: 0, y: y)) + path.addLine(to: CGPoint(x: size.width, y: y)) + y += grid + } + + ctx.stroke(path, with: .color(Color.purple.opacity(0.12)), lineWidth: 1) + } + + // Twinkling pixel stars + Canvas { ctx, size in + for star in Self.stars { + let alpha = 0.15 + 0.85 * (0.5 + 0.5 * sin(t * star.spd + star.ph)) + let rect = CGRect( + x: star.x * size.width - star.sz / 2, + y: star.y * size.height - star.sz / 2, + width: star.sz, height: star.sz + ) + ctx.fill(Path(rect), with: .color(Color.white.opacity(alpha))) + } + } + + // Purple ambient glow + RadialGradient( + colors: [SurvivorsTheme.backdropGlow, .clear], + center: UnitPoint(x: glowAX, y: glowAY), + startRadius: 30, + endRadius: 540 + ) + .blur(radius: 40) + + // Crimson ambient glow + RadialGradient( + colors: [SurvivorsTheme.backdropGlowSecondary, .clear], + center: UnitPoint(x: glowBX, y: glowBY), + startRadius: 24, + endRadius: 440 + ) + .blur(radius: 30) + } + } + .ignoresSafeArea() + } +} + +extension View { + func survivorsPanel(cornerRadius: CGFloat = 18) -> some View { + background(SurvivorsPanelBackground(cornerRadius: cornerRadius)) + } + + func survivorsShadow() -> some View { + shadow(color: Color.black.opacity(0.15), radius: 10, x: 0, y: 5) + } +} + +// MARK: - macOS lifecycle + +#if canImport(AppKit) +@MainActor +private final class NinjaGameAppDelegate: NSObject, NSApplicationDelegate { + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + func applicationWillTerminate(_ notification: Notification) { + if let client = SpacetimeClient.shared { + client.disconnect() + SpacetimeClient.shared = nil + } + } +} +#endif + +// MARK: - Entry point + +@main +struct NinjaGameApp: App { + #if canImport(AppKit) + @NSApplicationDelegateAdaptor(NinjaGameAppDelegate.self) private var appDelegate + #endif + + init() { + #if canImport(AppKit) + NSApplication.shared.setActivationPolicy(.regular) + #endif + } + + var body: some Scene { + WindowGroup("SpaceTimeDB Survivors") { + RootView() + .frame(minWidth: 700, minHeight: 560) + } + .windowStyle(.titleBar) + } +} + +// MARK: - App-level state machine + +private enum Screen { + case title // main menu + name entry + case lobbyBrowser // looking for a game + case lobby // waiting for match to start + case playing // full game +} + +// MARK: - Root View Model + +/// We need a global view model that connects on start, and lives across +/// the Lobby and Playing screens, instead of tying it to NinjaGameView. +@MainActor +@Observable +final class RootViewModel { + let audio = MusicPlayer() + var gameVM = NinjaGameViewModel() +} + +// MARK: - Root view + +struct RootView: View { + @State private var screen: Screen = .title + @State private var playerName: String = "Player \(Int.random(in: 1...99))" + @State private var titleOpacity = 0.0 + + @State private var vm = RootViewModel() + + var body: some View { + ZStack { + if screen != .playing { + SurvivorsBackdrop() + } + + switch screen { + case .title: + TitleView( + titleOpacity: titleOpacity, + vm: vm.gameVM, + onBrowseLobbies: { + vm.gameVM.initialName = playerName + vm.gameVM.clearPendingQuickJoinFromTitle() + vm.gameVM.start() + withAnimation(.easeIn(duration: 0.35)) { screen = .lobbyBrowser } + }, + onQuickJoin: { + vm.gameVM.initialName = playerName + vm.gameVM.scheduleQuickJoinFromTitle() + vm.gameVM.start() + }, + playerName: $playerName, + selectedEnvironment: $vm.gameVM.environment + ) + .transition(.opacity) + + case .lobbyBrowser: + LobbyBrowserView(vm: vm.gameVM) { action in + switch action { + case .resetName: + vm.gameVM.stop() + withAnimation { screen = .title } + case .quit: + vm.gameVM.stop() + withAnimation { screen = .title } + } + } + .transition(.asymmetric( + insertion: .opacity.combined(with: .scale(scale: 1.04)), + removal: .opacity + )) + + case .lobby: + LobbyView(vm: vm.gameVM) { action in + switch action { + case .resetName: + vm.gameVM.stop() + withAnimation { screen = .title } + case .quit: + vm.gameVM.stop() + withAnimation { screen = .title } + } + } + .transition(.asymmetric( + insertion: .opacity.combined(with: .scale(scale: 1.04)), + removal: .opacity + )) + + case .playing: + NinjaGameView( + isBackground: false, + isMuted: vm.audio.isMuted, + injectedVM: vm.gameVM, + onMuteToggle: { vm.audio.toggleMute() } + ) { action in + switch action { + case .resetName: + vm.gameVM.stop() + withAnimation { screen = .title } + case .quit: + vm.gameVM.stop() + withAnimation { screen = .title } + } + } onMusicChange: { playInGameMusic in + if playInGameMusic { + vm.audio.crossfadeToGame() + } else { + vm.audio.switchToTitleMusic() + } + } + .transition(.opacity) + } + } + .tint(SurvivorsTheme.accent) + .animation(.easeInOut(duration: 0.5), value: screen) + .onAppear { + vm.audio.playTitle() + withAnimation(.easeIn(duration: 1.4)) { titleOpacity = 1.0 } + } + .onChange(of: screen) { _, newScreen in + // Any screen outside active play uses title music. + if newScreen != .playing { + vm.audio.switchToTitleMusic() + } + } + .onChange(of: vm.gameVM.activeLobbyId) { _, newLobbyId in + if newLobbyId != nil && (screen == .lobbyBrowser || screen == .title) { + if vm.gameVM.isQuickJoinActive { + vm.gameVM.isQuickJoinActive = false + if !vm.gameVM.isPlaying { + SoundEffects.shared.play(.enterArena) + StartMatch.invoke() + } + withAnimation(.easeIn(duration: 0.35)) { screen = .playing } + } else if vm.gameVM.isPlaying { + SoundEffects.shared.play(.enterArena) + withAnimation(.easeIn(duration: 0.35)) { screen = .playing } + } else { + withAnimation(.easeIn(duration: 0.35)) { screen = .lobby } + } + } else if newLobbyId == nil && (screen == .lobby || screen == .playing) { + withAnimation(.easeIn(duration: 0.35)) { screen = .lobbyBrowser } + } + } + .onChange(of: vm.gameVM.isPlaying) { _, isPlaying in + // Auto transition based on backend Lobby.is_playing + if isPlaying && screen == .lobby { + SoundEffects.shared.play(.enterArena) + withAnimation(.easeIn(duration: 0.35)) { screen = .playing } + } else if !isPlaying && screen == .playing { + withAnimation(.easeIn(duration: 0.35)) { screen = .lobby } + } + } + } +} + +// MARK: - Title screen + +struct TitleView: View { + let titleOpacity: Double + var vm: NinjaGameViewModel + let onBrowseLobbies: () -> Void + let onQuickJoin: () -> Void + @Binding var playerName: String + @Binding var selectedEnvironment: SpacetimeEnvironment + + @State private var pulsePlay = false + @State private var isConnecting = false + + private var trimmedName: String { + playerName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canStart: Bool { !trimmedName.isEmpty } + + private var endpointLabel: String { + switch selectedEnvironment { + case .local: return "127.0.0.1:3000" + case .prod: return "maincloud.spacetimedb.com" + } + } + + var body: some View { + GeometryReader { geo in + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + Spacer(minLength: 20) + + // ── Title ── + VStack(spacing: 8) { + let logoSize = min(geo.size.width * 0.28, 180.0) + Image("spacetime_logo", bundle: .module) + .resizable() + .scaledToFit() + .frame(width: logoSize, height: logoSize) + .shadow(color: .black.opacity(0.18), radius: 6, x: 0, y: 3) + + Text("Ninja Wars") + .font(.system(size: 42, weight: .heavy, design: .rounded)) + .foregroundColor(Color(red: 0.90, green: 0.95, blue: 1.0)) + .shadow(color: Color(red: 0.2, green: 0.4, blue: 0.8).opacity(0.45), radius: 8, x: 0, y: 2) + + Text("Realtime multiplayer on SpacetimeDB") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(Color(white: 0.72)) + .padding(.top, 8) + } + .multilineTextAlignment(.center) + .minimumScaleFactor(0.5) + .opacity(titleOpacity) + .padding(.bottom, 44) + + // ── Controls ── + VStack(spacing: 14) { + // Name input + TextField("Enter your ninja name…", text: $playerName) + .textFieldStyle(.plain) + .font(.system(size: 16, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(Color.white.opacity(0.06)) + .overlay(Rectangle().strokeBorder(Color(red: 0.55, green: 0.82, blue: 1.0).opacity(0.40), lineWidth: 2)) + .onSubmit { + guard canStart else { return } + SoundEffects.shared.play(.buttonPress) + onQuickJoin() + } + + // Environment picker toggle + HStack(spacing: 0) { + ForEach(SpacetimeEnvironment.allCases) { env in + let isSelected = selectedEnvironment == env + Button { + SoundEffects.shared.play(.buttonPress) + selectedEnvironment = env + } label: { + Text(env.rawValue) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .foregroundColor(isSelected ? .white : Color(white: 0.85)) + .background(isSelected ? SurvivorsTheme.accent.opacity(0.92) : Color.clear) + } + .buttonStyle(.plain) + } + } + .background(Color.white.opacity(0.08)) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).strokeBorder(Color.white.opacity(0.22), lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .padding(.horizontal, 48) + + Text(endpointLabel) + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(Color(white: 0.62)) + .padding(.top, -6) + + // ── PLAY NOW ── + Button { + guard canStart, !isConnecting else { return } + SoundEffects.shared.play(.buttonPress) + isConnecting = true + onQuickJoin() + } label: { + HStack(spacing: 8) { + if isConnecting { + ProgressView().controlSize(.small).tint(.black) + Text("Connecting...") + .font(.system(size: 16, weight: .semibold, design: .rounded)) + } else { + Image(systemName: "star.fill") + Text("Quick Play") + .font(.system(size: 16, weight: .semibold, design: .rounded)) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(PixelButtonStyle(filled: true)) + .disabled(!canStart || isConnecting) + .opacity(canStart ? 1.0 : 0.4) + .keyboardShortcut(.defaultAction) + .padding(.top, 4) + + if isConnecting && !vm.connectionDetail.isEmpty { + Text(vm.connectionDetail) + .font(.system(size: 11, design: .rounded)) + .foregroundStyle(.white.opacity(0.80)) + .padding(.top, -6) + } + + if isConnecting { + Button("Cancel") { + SoundEffects.shared.play(.buttonPress) + isConnecting = false + vm.stop() + } + .buttonStyle(PixelButtonStyle()) + .padding(.bottom, 6) + } + + // ── Browse Lobbies ── + Button { + SoundEffects.shared.play(.buttonPress) + onBrowseLobbies() + } label: { + HStack(spacing: 8) { + Image(systemName: "person.3.fill") + Text("Browse Lobbies") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(PixelButtonStyle()) + .disabled(!canStart || isConnecting) + .opacity(canStart && !isConnecting ? 1.0 : 0.4) + + // ── Utility row ── + HStack(spacing: 14) { + Button(action: clearServer) { + Text("Clear Server") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundColor(.red.opacity(0.8)) + } + .buttonStyle(.plain) + + Text("·").foregroundColor(Color(white: 0.25)) + + Button(action: quitApplication) { + Text("Quit") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundColor(Color(white: 0.45)) + } + .buttonStyle(.plain) + } + .padding(.top, 6) + } + .frame(width: 380) + .opacity(titleOpacity) + .padding(.bottom, 32) + + Text("Realtime multiplayer powered by SpacetimeDB") + .font(.system(size: 11, design: .rounded)) + .foregroundStyle(.white.opacity(0.35)) + + Spacer(minLength: 20) + } + .frame(maxWidth: .infinity, minHeight: geo.size.height, alignment: .center) + } + } + .onAppear { + withAnimation(.easeInOut(duration: 1.8).repeatForever(autoreverses: true)) { + pulsePlay = true + } + } + } + + private func quitApplication() { + #if canImport(AppKit) + NSApplication.shared.terminate(nil) + #endif + } + + private func clearServer() { + SoundEffects.shared.play(.menuButton) + ClearServer.invoke() + } +} + + + +// MARK: - Lobby Browser Screen + +struct LobbyBrowserView: View { + let vm: NinjaGameViewModel + let onAction: (ExitAction) -> Void + + @State private var newLobbyName: String = "" + @State private var showingCreateForm = false + + var body: some View { + ZStack { + VStack(spacing: 24) { + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 4) { + Text("Lobbies") + .font(.system(size: 26, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + + HStack(spacing: 6) { + Rectangle() + .fill(vm.isConnected ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(vm.isConnected + ? "Online · \(vm.myPlayer?.name ?? "Joining...")" + : (vm.connectionDetail.isEmpty ? "Connecting..." : vm.connectionDetail)) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(vm.isConnected ? Color(white: 0.65) : .orange) + } + } + + Spacer() + + Button(action: { vm.refreshLobbies() }) { + Text("Refresh") + } + .buttonStyle(PixelButtonStyle()) + .disabled(!vm.isConnected) + } + + if showingCreateForm { + VStack(spacing: 12) { + HStack { + Text("Create Lobby") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + Spacer() + } + + TextField("Lobby name", text: $newLobbyName) + .textFieldStyle(.plain) + .font(.system(size: 14, weight: .medium, design: .rounded)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(Color.white.opacity(0.06)) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).strokeBorder(Color.white.opacity(0.24), lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + HStack(spacing: 10) { + Button("Cancel") { + withAnimation { showingCreateForm = false } + } + .buttonStyle(PixelButtonStyle()) + .frame(maxWidth: .infinity) + + Button("Create Lobby") { + SoundEffects.shared.play(.enterArena) + vm.isQuickJoinActive = false + vm.createLobbyWithRetry(name: newLobbyName) + withAnimation { showingCreateForm = false } + } + .buttonStyle(PixelButtonStyle(filled: true)) + .disabled(newLobbyName.isEmpty) + .frame(maxWidth: .infinity) + } + } + .padding(16) + .background(Color.white.opacity(0.05)) + .overlay(Rectangle().strokeBorder(Color(red: 0.55, green: 0.82, blue: 1.0).opacity(0.30), lineWidth: 2)) + } + + VStack(spacing: 0) { + HStack { + Text("Available Lobbies") + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(Color(white: 0.40)) + Spacer() + Text("\(vm.lobbies.count) / 50") + .font(.system(size: 10, weight: .medium, design: .rounded).monospacedDigit()) + .foregroundStyle(Color(white: 0.30)) + } + .padding(.horizontal, 4) + .padding(.bottom, 8) + + ScrollView { + VStack(spacing: 8) { + if vm.lobbies.isEmpty { + VStack(spacing: 8) { + Text("(zzz)") + .font(.system(size: 22, weight: .heavy, design: .rounded)) + .foregroundStyle(Color(white: 0.25)) + Text("No lobbies active") + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(Color(white: 0.30)) + } + .padding(.vertical, 40) + .frame(maxWidth: .infinity) + } else { + ForEach(vm.lobbies, id: \.id) { lobby in + let lobbyPlayerCount = vm.playerCount(forLobbyId: lobby.id) + let isFull = lobbyPlayerCount >= NinjaGameViewModel.maxPlayersPerLobby + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(lobby.name) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + + HStack(spacing: 10) { + Text(lobby.isPlaying ? "Playing" : "Waiting") + .foregroundStyle(lobby.isPlaying ? .orange : .green) + Text("\(lobbyPlayerCount)/\(NinjaGameViewModel.maxPlayersPerLobby)") + .foregroundStyle(isFull ? .red : Color(white: 0.50)) + } + .font(.system(size: 11, weight: .medium, design: .rounded)) + } + Spacer() + Button(isFull ? "Full" : "Join") { + SoundEffects.shared.play(.buttonPress) + vm.isQuickJoinActive = false + vm.joinLobbyWithRetry(lobbyId: lobby.id) + } + .buttonStyle(PixelButtonStyle(filled: !isFull)) + .disabled(isFull) + } + .padding(.vertical, 10) + .padding(.horizontal, 14) + .background(Color.white.opacity(0.05)) + .overlay(Rectangle().strokeBorder(Color(red: 0.55, green: 0.82, blue: 1.0).opacity(0.20), lineWidth: 1)) + } + } + } + } + .frame(height: 280) + } + + VStack(spacing: 10) { + if !showingCreateForm { + HStack(spacing: 10) { + Button(action: { + SoundEffects.shared.play(.enterArena) + vm.quickJoinFirstLobbyWithRetry(waitForLobbySnapshot: true, attemptsRemaining: 6) + }) { + HStack(spacing: 6) { + Image(systemName: "star.fill") + Text("Quick Join") + } + .frame(maxWidth: .infinity) + } + .keyboardShortcut(.defaultAction) + .buttonStyle(PixelButtonStyle(filled: true)) + .controlSize(.large) + .disabled(!vm.isConnected) + + Button(action: { + SoundEffects.shared.play(.buttonPress) + withAnimation { + showingCreateForm = true + newLobbyName = "\(vm.myPlayer?.name ?? "Player")'s Lobby" + } + if !vm.hasJoined { + vm.ensureIdentityRegistered(allowFallback: true) + } + }) { + Text("Create") + } + .buttonStyle(PixelButtonStyle()) + .controlSize(.large) + .disabled(!vm.isConnected) + } + + if vm.isConnected && !vm.hasJoined { + Text("Waiting for player registration. Try Quick Join or Create.") + .font(.system(size: 9, weight: .medium, design: .rounded)) + .foregroundStyle(Color(white: 0.38)) + .frame(maxWidth: .infinity, alignment: .center) + } + } + + Button(role: .destructive, action: { + SoundEffects.shared.play(.buttonPress) + onAction(.quit) + }) { + Text("Back") + .frame(maxWidth: .infinity) + } + .buttonStyle(PixelButtonStyle(danger: true)) + .controlSize(.large) + .padding(.top, showingCreateForm ? 0 : 6) + } + } + .frame(width: 480) + .padding(.horizontal, 32) + .padding(.vertical, 32) + .pixelPanel() + .shadow(color: Color(red: 0.3, green: 0.6, blue: 1.0).opacity(0.15), radius: 18, x: 0, y: 8) + } + } +} + +struct LobbyView: View { + let vm: NinjaGameViewModel + let onAction: (ExitAction) -> Void + + var currentLobby: Lobby? { + vm.myLobby + } + + var humanLobbyPlayers: [Player] { + guard let lobbyId = vm.activeLobbyId else { return [] } + return vm.players.filter { $0.lobbyId == lobbyId && !$0.name.hasPrefix("Bot ") } + } + + var lobbyPlayers: [Player] { + if currentLobby?.isPlaying == true { + return vm.playersInMyLobby + } + return humanLobbyPlayers + } + + var lobbyPlayerCount: Int { + lobbyPlayers.count + } + + var humanPlayerCount: Int { + humanLobbyPlayers.count + } + + var readyHumanCount: Int { + humanLobbyPlayers.filter { $0.isReady }.count + } + + var botCount: Int { + max(0, lobbyPlayerCount - humanPlayerCount) + } + + var openSlots: Int { + max(0, NinjaGameViewModel.maxPlayersPerLobby - lobbyPlayerCount) + } + + var lobbyStatusText: String { + guard let lobby = currentLobby else { return "No active lobby" } + return lobby.isPlaying ? "Playing" : "Waiting" + } + + var allReady: Bool { + !humanLobbyPlayers.isEmpty && humanLobbyPlayers.allSatisfy { $0.isReady } + } + + var myPlayerIsReady: Bool { + vm.myPlayer?.isReady ?? false + } + + var body: some View { + ZStack { + VStack(spacing: 22) { + Text("Lobby") + .font(.system(size: 26, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + + HStack(spacing: 6) { + Rectangle() + .fill(vm.isConnected ? Color.green : Color.red) + .frame(width: 8, height: 8) + Text(vm.isConnected ? "CONNECTED" : "DISCONNECTED") + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(vm.isConnected ? Color(white: 0.60) : .red) + } + + if !vm.connectionDetail.isEmpty { + Text(vm.connectionDetail) + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(.red) + } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(currentLobby?.name ?? "Unknown lobby") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + Spacer() + Text(lobbyStatusText) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(currentLobby?.isPlaying == true ? .orange : .green) + } + + HStack(spacing: 8) { + Text("ID #\(currentLobby?.id ?? 0)") + Text("·") + Text("\(lobbyPlayerCount)/\(NinjaGameViewModel.maxPlayersPerLobby) players") + Text("·") + Text("\(readyHumanCount)/\(max(1, humanPlayerCount)) ready") + } + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(Color(white: 0.48)) + + HStack { + Text("\(openSlots) open slots") + if botCount > 0 { Text("· \(botCount) bots") } + Spacer() + } + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(Color(white: 0.32)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.white.opacity(0.06)) + .overlay(Rectangle().strokeBorder(Color(red: 0.55, green: 0.82, blue: 1.0).opacity(0.25), lineWidth: 2)) + + // Player list + VStack(spacing: 6) { + HStack { + Text("Players") + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(Color(white: 0.38)) + Spacer() + } + + ForEach(lobbyPlayers, id: \.id) { player in + HStack { + Text((player.id == vm.userId ? "● " : " ") + player.name) + .font(.system(size: 13, weight: player.id == vm.userId ? .semibold : .medium, design: .rounded)) + .foregroundStyle(player.id == vm.userId ? .white : Color(white: 0.72)) + Spacer() + Text(player.isReady ? "Ready" : "Waiting") + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(player.isReady ? .green : Color(white: 0.32)) + } + .padding(.vertical, 7) + .padding(.horizontal, 10) + .background(Color.white.opacity(player.id == vm.userId ? 0.10 : 0.05)) + .overlay(Rectangle().strokeBorder( + player.id == vm.userId + ? Color(red: 0.55, green: 0.82, blue: 1.0).opacity(0.35) + : Color(white: 0.20).opacity(0.35), + lineWidth: player.id == vm.userId ? 2 : 1 + )) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + + EventFeedView( + events: vm.recentEvents, + title: "Recent Events", + maxVisible: 5, + padded: true + ) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(spacing: 8) { + Text("Match controls") + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(Color(white: 0.35)) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: { + SoundEffects.shared.play(.buttonPress) + ToggleReady.invoke() + }) { + HStack(spacing: 6) { + Image(systemName: myPlayerIsReady ? "xmark" : "checkmark") + Text(myPlayerIsReady ? "Not Ready" : "Ready Up") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(PixelButtonStyle(filled: !myPlayerIsReady, danger: myPlayerIsReady)) + .controlSize(.large) + .disabled(!vm.isConnected || !vm.hasJoined) + + Button(action: { + SoundEffects.shared.play(.enterArena) + StartMatch.invoke() + }) { + HStack(spacing: 6) { + Image(systemName: "play.fill") + Text("Start Match") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(PixelButtonStyle(filled: true, accentColor: Color(red: 0.15, green: 0.75, blue: 0.30))) + .controlSize(.large) + .disabled(!vm.isConnected || !vm.hasJoined) + + Button(role: .destructive, action: { + SoundEffects.shared.play(.buttonPress) + LeaveLobby.invoke() + }) { + Text("Leave Lobby") + .frame(maxWidth: .infinity) + } + .buttonStyle(PixelButtonStyle(danger: true)) + .controlSize(.large) + } + } + .frame(width: 400) + .padding(.horizontal, 24) + .padding(.vertical, 24) + .pixelPanel() + .shadow(color: Color(red: 0.3, green: 0.6, blue: 1.0).opacity(0.12), radius: 16, x: 0, y: 6) + } + } +} + +// MARK: - Music player (two tracks, crossfade) + +@MainActor +@Observable +final class MusicPlayer { + private enum MusicMode { + case title + case game + } + + private struct FadeState { + let startTime: TimeInterval + let duration: TimeInterval + let fromTitleLevel: Float + let toTitleLevel: Float + let fromGameLevel: Float + let toGameLevel: Float + } + + var isMuted: Bool = false { + didSet { + applyEffectiveVolumes() + } + } + + private let titleNominalVolume: Float = 0.55 + private let gameNominalVolume: Float = 0.65 + private let fadeTickInterval: TimeInterval = 1.0 / 60.0 + + private var titlePlayer: AVAudioPlayer? + private var gamePlayer: AVAudioPlayer? + private var mode: MusicMode = .title + + // Logical (unmuted) levels. + private var titleLevel: Float = 0 + private var gameLevel: Float = 0 + private var fadeState: FadeState? + private var fadeTimer: Timer? + private var isInterrupted = false + +#if canImport(UIKit) + private var interruptionObserver: NSObjectProtocol? + private var routeChangeObserver: NSObjectProtocol? + private var mediaResetObserver: NSObjectProtocol? +#endif + + init() { + titlePlayer = makePlayer(resource: "SpaceTimeDB Survivors", exts: ["m4a", "wav"]) + gamePlayer = makePlayer(resource: "SpaceTimeDB Survivors - Alternate Music", exts: ["m4a", "wav"]) + applyEffectiveVolumes() + installInterruptionObserversIfSupported() + } + + func playTitle() { + transition(to: .title, duration: 2.5, force: true) + } + + func crossfadeToGame() { + transition(to: .game, duration: 1.5) + } + + func switchToTitleMusic() { + transition(to: .title, duration: 1.0) + } + + func toggleMute() { + isMuted.toggle() + } + + private func transition(to newMode: MusicMode, duration: TimeInterval, force: Bool = false) { + if !force && mode == newMode && fadeState == nil { + resumePlayersForCurrentLevels() + return + } + mode = newMode + if isInterrupted { + // Defer playback while interrupted; keep logical targets consistent. + titleLevel = (newMode == .title) ? titleNominalVolume : 0 + gameLevel = (newMode == .game) ? gameNominalVolume : 0 + fadeState = nil + fadeTimer?.invalidate() + fadeTimer = nil + applyEffectiveVolumes() + return + } + // During a crossfade both tracks must be playing before we adjust volumes. + // Ensure the incoming track starts (at its current level) before fading. + ensurePlayerLoaded(for: .title) + ensurePlayerLoaded(for: .game) + startPlaybackIfNeeded(titlePlayer) + startPlaybackIfNeeded(gamePlayer) + let targetTitle = (newMode == .title) ? titleNominalVolume : 0 + let targetGame = (newMode == .game) ? gameNominalVolume : 0 + startFade(toTitleLevel: targetTitle, toGameLevel: targetGame, duration: duration) + } + + private func ensurePlayerLoaded(for mode: MusicMode) { + switch mode { + case .title: + if titlePlayer == nil { + titlePlayer = makePlayer(resource: "SpaceTimeDB Survivors", exts: ["m4a", "wav"]) + applyEffectiveVolumes() + } + case .game: + if gamePlayer == nil { + gamePlayer = makePlayer(resource: "SpaceTimeDB Survivors - Alternate Music", exts: ["m4a", "wav"]) + applyEffectiveVolumes() + } + } + } + + private func startPlaybackIfNeeded(_ player: AVAudioPlayer?) { + guard let player else { return } + guard !isInterrupted else { return } + if !player.isPlaying { + player.prepareToPlay() + if !player.play() { + print("[MusicPlayer] Failed to start playback for \(player.url?.lastPathComponent ?? "unknown")") + } + } + } + + private func resumePlayersForCurrentLevels() { + ensurePlayerLoaded(for: .title) + ensurePlayerLoaded(for: .game) + if titleLevel > 0.001 { + startPlaybackIfNeeded(titlePlayer) + } else { + titlePlayer?.pause() + } + if gameLevel > 0.001 { + startPlaybackIfNeeded(gamePlayer) + } else { + gamePlayer?.pause() + } + applyEffectiveVolumes() + } + + private func startFade(toTitleLevel: Float, toGameLevel: Float, duration: TimeInterval) { + fadeTimer?.invalidate() + fadeTimer = nil + + let clampedDuration = max(0, duration) + if clampedDuration == 0 { + titleLevel = toTitleLevel + gameLevel = toGameLevel + applyEffectiveVolumes() + pauseSilentPlayers(titleTarget: toTitleLevel, gameTarget: toGameLevel) + return + } + + fadeState = FadeState( + startTime: Date.timeIntervalSinceReferenceDate, + duration: clampedDuration, + fromTitleLevel: titleLevel, + toTitleLevel: toTitleLevel, + fromGameLevel: gameLevel, + toGameLevel: toGameLevel + ) + + let timer = Timer(timeInterval: fadeTickInterval, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.tickFade() + } + } + fadeTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + + private func tickFade() { + guard let fade = fadeState else { return } + let now = Date.timeIntervalSinceReferenceDate + let t = Float(max(0, min(1, (now - fade.startTime) / fade.duration))) + titleLevel = fade.fromTitleLevel + (fade.toTitleLevel - fade.fromTitleLevel) * t + gameLevel = fade.fromGameLevel + (fade.toGameLevel - fade.fromGameLevel) * t + applyEffectiveVolumes() + + if t >= 1 { + let finishedTitleTarget = fade.toTitleLevel + let finishedGameTarget = fade.toGameLevel + fadeState = nil + fadeTimer?.invalidate() + fadeTimer = nil + pauseSilentPlayers(titleTarget: finishedTitleTarget, gameTarget: finishedGameTarget) + } + } + + /// Pauses whichever player faded down to silence. + /// Taking the targets as parameters avoids reading `fadeState` after it is cleared. + private func pauseSilentPlayers(titleTarget: Float, gameTarget: Float) { + if titleTarget <= 0.001 { titlePlayer?.pause() } + if gameTarget <= 0.001 { gamePlayer?.pause() } + } + + private func applyEffectiveVolumes() { + let titleVolume = isMuted ? 0 : titleLevel + let gameVolume = isMuted ? 0 : gameLevel + titlePlayer?.volume = titleVolume + gamePlayer?.volume = gameVolume + } + + private func makePlayer(resource: String, exts: [String]) -> AVAudioPlayer? { + for ext in exts { + guard let url = Bundle.module.url(forResource: resource, withExtension: ext) else { continue } + let player = try? AVAudioPlayer(contentsOf: url) + player?.numberOfLoops = -1 + player?.prepareToPlay() + if player != nil { + return player + } + } + print("[MusicPlayer] Missing or unreadable resource: \(resource)") + return nil + } + +#if canImport(UIKit) + // MARK: - iOS audio session interruption (calls/Siri/alarms) + private func installInterruptionObserversIfSupported() { + interruptionObserver = NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, + object: AVAudioSession.sharedInstance(), + queue: .main + ) { [weak self] notification in + Task { @MainActor [weak self] in + self?.handleAudioInterruption(notification) + } + } + routeChangeObserver = NotificationCenter.default.addObserver( + forName: AVAudioSession.routeChangeNotification, + object: AVAudioSession.sharedInstance(), + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, !self.isInterrupted else { return } + self.resumePlayersForCurrentLevels() + } + } + mediaResetObserver = NotificationCenter.default.addObserver( + forName: AVAudioSession.mediaServicesWereResetNotification, + object: AVAudioSession.sharedInstance(), + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + self.titlePlayer = self.makePlayer(resource: "SpaceTimeDB Survivors", exts: ["m4a", "wav"]) + self.gamePlayer = self.makePlayer(resource: "SpaceTimeDB Survivors - Alternate Music", exts: ["m4a", "wav"]) + self.resumePlayersForCurrentLevels() + } + } + } + + private func handleAudioInterruption(_ notification: Notification) { + guard let info = notification.userInfo, + let rawType = info[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: rawType) else { + return + } + + switch type { + case .began: + isInterrupted = true + // Snap any active fade to its target so state remains deterministic. + if let fade = fadeState { + titleLevel = fade.toTitleLevel + gameLevel = fade.toGameLevel + fadeState = nil + fadeTimer?.invalidate() + fadeTimer = nil + } + titlePlayer?.pause() + gamePlayer?.pause() + case .ended: + let shouldResume = (info[AVAudioSessionInterruptionOptionKey] as? UInt) + .map { AVAudioSession.InterruptionOptions(rawValue: $0).contains(.shouldResume) } ?? false + isInterrupted = false + if shouldResume { + resumePlayersForCurrentLevels() + } + @unknown default: + break + } + } +#else + // MARK: - macOS audio route / hardware change recovery + // macOS has no AVAudioSession, but hardware route changes (e.g. headphones + // plug/unplug, Bluetooth handoff, display sleep) can silently reset the + // underlying audio unit and cause players to stop. We recover by observing + // AVAudioPlayer's notification and restarting playback if needed. + private var routeChangeObserver: NSObjectProtocol? + + private func installInterruptionObserversIfSupported() { + // AVAudioPlayer doesn't stop on macOS route changes, but we watch for + // app-level background/foreground transitions that can drop the audio + // device on macOS (e.g. display sleep on Apple Silicon). + routeChangeObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + // If the player silently stopped while the app was inactive, restart. + self.isInterrupted = false + self.resumePlayersForCurrentLevels() + } + } + } +#endif +} + +// MARK: - Sound Effects + +/// Synthesizes all UI sound effects using AVAudioEngine. +/// +/// Design goals / robustness: +/// - All PCM buffers are pre-synthesized on a background thread at init, so +/// `play()` only schedules a pre-built buffer — zero main-thread synthesis. +/// - A fixed pool of `AVAudioPlayerNode`s per sound handles polyphonic rapid +/// repeats (e.g. picking up several weapons in a row) without any node leak. +/// - Handles `AVAudioEngineConfigurationChangeNotification` (headphones +/// plug/unplug, BT route change, sleep/wake on macOS) by restarting the +/// engine and re-attaching all nodes automatically. +/// - A silent warm-up buffer is played at start so the audio graph is fully +/// active before the first real sound fires. +@MainActor +final class SoundEffects { + static let shared = SoundEffects() + + enum Sound: CaseIterable { + case buttonPress // soft 2-note chime C5→E5 + case menuButton // slightly lower chime B4→D5 + case enterArena // rising major arpeggio C5 E5 G5 + case menuOpen // descending minor 2nd E5→Eb5 + case menuClose // ascending perfect 4th C5→F5 + case respawn // bright 4-note fanfare C5 E5 G5 C6 + case weaponPickup // metallic ting (high sine, fast decay) + case attack // percussive thwack (low sawtooth) + case death // dramatic descending tritone swell + case muteToggle // single muffled pop + } + + var isMuted = false { + didSet { + if !isMuted { + flushPendingSounds() + } + } + } + + // MARK: - Private + + private let engine = AVAudioEngine() + private var mixer: AVAudioMixerNode { engine.mainMixerNode } + /// Permanent node used to keep a valid graph before any SFX pool exists. + private let bootstrapNode = AVAudioPlayerNode() + + /// Each sound gets a small round-robin pool of player nodes so rapid + /// repeats of the same sound overlap cleanly without node thrash. + private var pools: [Sound: NodePool] = [:] + + /// Pre-built PCM buffers, keyed by sound. Set from background thread, + /// then only read on main actor, so access is safe after init completes. + private var buffers: [Sound: AVAudioPCMBuffer] = [:] + private var buffersReady = false + private var pendingSounds: [Sound] = [] + private let maxPendingSounds = 24 + private var isEngineInterrupted = false + private var lastPlayedAt: [Sound: TimeInterval] = [:] + private var burstWindow: [Sound: (start: TimeInterval, count: Int)] = [:] + private var globalBurstWindow: (start: TimeInterval, count: Int) = (start: 0, count: 0) + + private var configChangeObserver: NSObjectProtocol? +#if canImport(UIKit) + private var interruptionObserver: NSObjectProtocol? + private var routeChangeObserver: NSObjectProtocol? + private var mediaResetObserver: NSObjectProtocol? +#else + private var appActiveObserver: NSObjectProtocol? +#endif + + private init() { + ensureBootstrapAttached() + // Start the engine immediately so the output node format is available. + restartEngine() + // Synthesize buffers + warm up on a background thread. + buildBuffersAndWarmUp() + // Receive route-change / config-reset notifications. + configChangeObserver = NotificationCenter.default.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: engine, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.handleEngineReset() } + } + installInterruptionObserversIfSupported() + } + + // MARK: - Public API + + func play(_ sound: Sound) { + guard !isMuted else { return } + let now = monotonicNow() + guard !shouldDrop(sound: sound, now: now) else { return } + guard consumeGlobalBudget(now: now) else { return } + guard buffersReady, !isEngineInterrupted else { + enqueuePending(sound) + return + } + playNow(sound) + } + + // MARK: - Engine lifecycle + + private func restartEngine() { + ensureBootstrapAttached() + if engine.isRunning { engine.stop() } + do { + // With bootstrap node attached, start is safe on macOS and iOS. + try engine.start() + } catch { + print("[SoundEffects] AVAudioEngine start failed: \(error)") + } + } + + private func handleEngineReset() { + recoverEngine() + flushPendingSounds() + } + + // MARK: - Pool management + + private func pool(for sound: Sound) -> NodePool { + if let existing = pools[sound] { return existing } + let p = NodePool(size: poolSize(for: sound), engine: engine, mixer: mixer) + pools[sound] = p + return p + } + + private func poolSize(for sound: Sound) -> Int { + switch sound { + case .weaponPickup: return 2 + case .attack: return 2 + default: return 2 + } + } + + // MARK: - Buffer synthesis (background) + + private func buildBuffersAndWarmUp() { + // Capture definitions as sendable value types for the background task. + let definitions = SoundEffects.soundDefinitions() + Task.detached(priority: .userInitiated) { + var built: [Sound: AVAudioPCMBuffer] = [:] + for (sound, def) in definitions { + if let buf = Self.synthesize(def) { built[sound] = buf } + } + // Deliver results and warm up back on main actor. + await MainActor.run { + self.buffers = built + self.buffersReady = true + self.warmUpEngine(sampleRate: 44100) + self.flushPendingSounds() + } + } + } + + /// Play a single frame of silence to prime the audio graph. + private func warmUpEngine(sampleRate: Double) { + guard ensureEngineRunning() else { return } + guard let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1), + let buf = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 1) else { return } + buf.frameLength = 1 + buf.floatChannelData![0][0] = 0 + let primer = AVAudioPlayerNode() + engine.attach(primer) + engine.connect(primer, to: mixer, format: format) + primer.play() + primer.scheduleBuffer(buf, completionCallbackType: .dataRendered) { [weak self] _ in + Task { @MainActor [weak self] in self?.engine.detach(primer) } + } + } + + private func playNow(_ sound: Sound) { + guard let buffer = buffers[sound] else { + enqueuePending(sound) + return + } + guard ensureEngineRunning() else { + enqueuePending(sound) + return + } + let played = pool(for: sound).play(buffer: buffer, through: engine) + if !played { + enqueuePending(sound) + } + } + + private func enqueuePending(_ sound: Sound) { + if isBurstProne(sound), pendingSounds.last == sound { + return + } + if pendingSounds.count >= maxPendingSounds { + pendingSounds.removeFirst(pendingSounds.count - maxPendingSounds + 1) + } + pendingSounds.append(sound) + } + + private func flushPendingSounds() { + guard !isMuted, buffersReady, !isEngineInterrupted else { return } + guard !pendingSounds.isEmpty else { return } + let queued = pendingSounds + pendingSounds.removeAll(keepingCapacity: true) + let now = monotonicNow() + for sound in queued { + if shouldDrop(sound: sound, now: now) { + continue + } + if !consumeGlobalBudget(now: now) { + break + } + playNow(sound) + } + } + + private func monotonicNow() -> TimeInterval { + ProcessInfo.processInfo.systemUptime + } + + private func isBurstProne(_ sound: Sound) -> Bool { + switch sound { + case .attack, .weaponPickup: + return true + default: + return false + } + } + + private func limits(for sound: Sound) -> (minGap: TimeInterval, maxPerWindow: Int) { + switch sound { + case .attack: + // Combat can emit very dense hits; keep this conservative to avoid + // audio I/O overload while preserving responsiveness. + return (0.12, 2) + case .weaponPickup: + return (0.10, 2) + case .menuOpen, .menuClose: + return (0.08, 2) + default: + return (0.0, 8) + } + } + + private func shouldDrop(sound: Sound, now: TimeInterval) -> Bool { + let rule = limits(for: sound) + if rule.minGap > 0, let last = lastPlayedAt[sound], (now - last) < rule.minGap { + return true + } + + let windowDuration: TimeInterval = 0.10 + var state = burstWindow[sound] ?? (start: now, count: 0) + if (now - state.start) > windowDuration { + state = (start: now, count: 0) + } + if state.count >= rule.maxPerWindow { + burstWindow[sound] = state + return true + } + state.count += 1 + burstWindow[sound] = state + lastPlayedAt[sound] = now + return false + } + + private func consumeGlobalBudget(now: TimeInterval) -> Bool { + let windowDuration: TimeInterval = 0.10 + let maxPerWindow = 5 + if (now - globalBurstWindow.start) > windowDuration { + globalBurstWindow = (start: now, count: 0) + } + guard globalBurstWindow.count < maxPerWindow else { return false } + globalBurstWindow.count += 1 + return true + } + + private func ensureEngineRunning() -> Bool { + if engine.isRunning { + return true + } + recoverEngine() + return engine.isRunning + } + + private func recoverEngine() { + ensureBootstrapAttached() + for pool in pools.values { + pool.reattach(to: engine, mixer: mixer) + } + restartEngine() + } + + private func ensureBootstrapAttached() { + if !engine.attachedNodes.contains(bootstrapNode) { + engine.attach(bootstrapNode) + if let format = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1) { + engine.connect(bootstrapNode, to: mixer, format: format) + } + } + } + + // MARK: - Sound definitions + + private enum Waveform: Sendable { case sine, triangle, sawtooth } + + private struct NoteSpec: Sendable { + let freq: Double; let start: Double; let dur: Double + } + private struct SoundDef: Sendable { + let notes: [NoteSpec]; let wave: Waveform; let gain: Float + } + + // Waveform is not Sendable by default since it's an enum in a non-Sendable context. + // Explicitly mark it for Task.detached. + private static func soundDefinitions() -> [(Sound, SoundDef)] { + [ + (.buttonPress, .init(notes: [.init(freq: 523.25, start: 0.00, dur: 0.06), + .init(freq: 659.25, start: 0.06, dur: 0.06)], + wave: .sine, gain: 0.18)), + (.menuButton, .init(notes: [.init(freq: 493.88, start: 0.00, dur: 0.05), + .init(freq: 587.33, start: 0.05, dur: 0.06)], + wave: .sine, gain: 0.14)), + (.enterArena, .init(notes: [.init(freq: 523.25, start: 0.00, dur: 0.07), + .init(freq: 659.25, start: 0.07, dur: 0.07), + .init(freq: 783.99, start: 0.14, dur: 0.10)], + wave: .sine, gain: 0.20)), + (.menuOpen, .init(notes: [.init(freq: 659.25, start: 0.00, dur: 0.05), + .init(freq: 622.25, start: 0.05, dur: 0.08)], + wave: .triangle, gain: 0.15)), + (.menuClose, .init(notes: [.init(freq: 523.25, start: 0.00, dur: 0.05), + .init(freq: 698.46, start: 0.05, dur: 0.08)], + wave: .triangle, gain: 0.15)), + (.respawn, .init(notes: [.init(freq: 523.25, start: 0.00, dur: 0.07), + .init(freq: 659.25, start: 0.07, dur: 0.07), + .init(freq: 783.99, start: 0.14, dur: 0.07), + .init(freq: 1046.5, start: 0.21, dur: 0.14)], + wave: .sine, gain: 0.22)), + (.weaponPickup, .init(notes: [.init(freq: 1174.66, start: 0.00, dur: 0.04), + .init(freq: 1396.91, start: 0.03, dur: 0.05)], + wave: .sine, gain: 0.25)), + (.attack, .init(notes: [.init(freq: 180.0, start: 0.00, dur: 0.03), + .init(freq: 120.0, start: 0.03, dur: 0.04)], + wave: .sawtooth, gain: 0.28)), + (.death, .init(notes: [.init(freq: 440.0, start: 0.00, dur: 0.12), + .init(freq: 311.13, start: 0.10, dur: 0.14), + .init(freq: 220.0, start: 0.22, dur: 0.20)], + wave: .triangle, gain: 0.30)), + (.muteToggle, .init(notes: [.init(freq: 300.0, start: 0.00, dur: 0.04)], + wave: .sine, gain: 0.12)), + ] + } + + // MARK: - PCM synthesis (pure function, runs on background thread) + + private nonisolated static func synthesize(_ def: SoundDef) -> AVAudioPCMBuffer? { + let sampleRate: Double = 44100 + let totalDuration = def.notes.map { $0.start + $0.dur + 0.02 }.max() ?? 0.1 + let frameCount = AVAudioFrameCount(totalDuration * sampleRate) + guard let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1), + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return nil } + buffer.frameLength = frameCount + let data = buffer.floatChannelData![0] + for i in 0..= noteFrames - releaseFrames { + env = Double(noteFrames - i) / Double(releaseFrames) + } else { + env = 1.0 + } + data[gf] += Float(raw * env * Double(def.gain)) + } + } + return buffer + } + +#if canImport(UIKit) + private func installInterruptionObserversIfSupported() { + interruptionObserver = NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, + object: AVAudioSession.sharedInstance(), + queue: .main + ) { [weak self] note in + Task { @MainActor [weak self] in + self?.handleInterruption(note) + } + } + routeChangeObserver = NotificationCenter.default.addObserver( + forName: AVAudioSession.routeChangeNotification, + object: AVAudioSession.sharedInstance(), + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, !self.isEngineInterrupted else { return } + self.recoverEngine() + self.flushPendingSounds() + } + } + mediaResetObserver = NotificationCenter.default.addObserver( + forName: AVAudioSession.mediaServicesWereResetNotification, + object: AVAudioSession.sharedInstance(), + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + self.isEngineInterrupted = false + self.recoverEngine() + self.flushPendingSounds() + } + } + } + + private func handleInterruption(_ notification: Notification) { + guard let info = notification.userInfo, + let rawType = info[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: rawType) else { + return + } + + switch type { + case .began: + isEngineInterrupted = true + if engine.isRunning { + engine.pause() + } + case .ended: + let shouldResume = (info[AVAudioSessionInterruptionOptionKey] as? UInt) + .map { AVAudioSession.InterruptionOptions(rawValue: $0).contains(.shouldResume) } ?? false + isEngineInterrupted = false + if shouldResume { + recoverEngine() + flushPendingSounds() + } + @unknown default: + break + } + } +#else + private func installInterruptionObserversIfSupported() { + appActiveObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.recoverEngine() + self?.flushPendingSounds() + } + } + } +#endif +} + +// MARK: - NodePool + +/// A fixed-size round-robin pool of `AVAudioPlayerNode`s for one sound type. +/// Prevents node exhaustion under rapid repeated triggers. +@MainActor +private final class NodePool { + private var nodes: [AVAudioPlayerNode] = [] + private var cursor = 0 + private weak var engine: AVAudioEngine? + private weak var mixer: AVAudioMixerNode? + + init(size: Int, engine: AVAudioEngine, mixer: AVAudioMixerNode) { + self.engine = engine + self.mixer = mixer + nodes = (0.. Bool { + guard engine.isRunning else { + print("[NodePool] Engine not running — skipping sound") + return false + } + let node = nodes[cursor % nodes.count] + cursor += 1 + // Stop any currently-playing sound on this node slot (round-robin eviction). + if node.isPlaying { node.stop() } + node.scheduleBuffer(buffer, at: nil, options: .interrupts) + node.play() + return true + } + + /// Called after an engine configuration reset to re-attach nodes. + func reattach(to engine: AVAudioEngine, mixer: AVAudioMixerNode) { + self.engine = engine + self.mixer = mixer + for node in nodes { + if !engine.attachedNodes.contains(node) { + Self.attach(node: node, engine: engine, mixer: mixer) + } + } + } + + private static func makeNode(engine: AVAudioEngine, mixer: AVAudioMixerNode) -> AVAudioPlayerNode { + let node = AVAudioPlayerNode() + attach(node: node, engine: engine, mixer: mixer) + return node + } + + private static func attach(node: AVAudioPlayerNode, engine: AVAudioEngine, mixer: AVAudioMixerNode) { + engine.attach(node) + // Use a fixed low-overhead mono format matching our synthesis rate. + if let format = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1) { + engine.connect(node, to: mixer, format: format) + } + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameEffects.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameEffects.swift new file mode 100644 index 00000000000..c20be0d908c --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameEffects.swift @@ -0,0 +1,172 @@ +import SwiftUI +import Observation + +// MARK: - Effect Manager + +@MainActor +@Observable +class EffectManager { + static let shared = EffectManager() + + struct ActiveEffect: Identifiable { + enum Kind { + case particle(color: Color, velocity: CGVector) + case floatingText(text: String, color: Color) + } + let id = UUID() + let kind: Kind + var x: CGFloat + var y: CGFloat + var opacity: Double = 1.0 + var scale: CGFloat = 1.0 + var createdAt: TimeInterval + var lifetime: TimeInterval + } + + private(set) var activeEffects: [ActiveEffect] = [] + + func spawnHit(x: Float, y: Float, value: String?) { + let now = Date.timeIntervalSinceReferenceDate + // Floating text + activeEffects.append(ActiveEffect( + kind: .floatingText(text: value ?? "HIT", color: .red), + x: CGFloat(x), + y: CGFloat(y) - 20, + createdAt: now, + lifetime: 0.8 + )) + + // Red particles + for _ in 0..<6 { + activeEffects.append(ActiveEffect( + kind: .particle(color: .red, velocity: CGVector(dx: CGFloat.random(in: -40...40), dy: CGFloat.random(in: -40...40))), + x: CGFloat(x), + y: CGFloat(y), + createdAt: now, + lifetime: 0.4 + )) + } + } + + func spawnKill(x: Float, y: Float) { + let now = Date.timeIntervalSinceReferenceDate + activeEffects.append(ActiveEffect( + kind: .floatingText(text: "KILL!", color: .orange), + x: CGFloat(x), + y: CGFloat(y) - 30, + createdAt: now, + lifetime: 1.2 + )) + + // Gold particles + for _ in 0..<12 { + activeEffects.append(ActiveEffect( + kind: .particle(color: .orange, velocity: CGVector(dx: CGFloat.random(in: -60...60), dy: CGFloat.random(in: -60...60))), + x: CGFloat(x), + y: CGFloat(y), + createdAt: now, + lifetime: 0.6 + )) + } + } + + func spawnPickup(x: Float, y: Float, value: String) { + let now = Date.timeIntervalSinceReferenceDate + activeEffects.append(ActiveEffect( + kind: .floatingText(text: value, color: Color(red: 0.55, green: 0.82, blue: 1.0)), + x: CGFloat(x), + y: CGFloat(y) - 20, + createdAt: now, + lifetime: 1.0 + )) + } + + func spawnDeath(x: Float, y: Float) { + let now = Date.timeIntervalSinceReferenceDate + activeEffects.append(ActiveEffect( + kind: .floatingText(text: "ELIMINATED", color: .gray), + x: CGFloat(x), + y: CGFloat(y) - 20, + createdAt: now, + lifetime: 1.5 + )) + + // Dark/smoke particles + for _ in 0..<20 { + activeEffects.append(ActiveEffect( + kind: .particle(color: Color(white: 0.2), velocity: CGVector(dx: CGFloat.random(in: -80...80), dy: CGFloat.random(in: -80...80))), + x: CGFloat(x), + y: CGFloat(y), + createdAt: now, + lifetime: 1.0 + )) + } + } + + func update(dt: Double, now: TimeInterval) { + activeEffects = activeEffects.compactMap { effect in + let age = now - effect.createdAt + guard age < effect.lifetime else { return nil } + + var updated = effect + let progress = age / effect.lifetime + updated.opacity = 1.0 - pow(progress, 2) + + switch effect.kind { + case .particle(_, let velocity): + updated.x += velocity.dx * CGFloat(dt) + updated.y += velocity.dy * CGFloat(dt) + updated.scale = 1.0 - progress + case .floatingText: + updated.y -= 30 * CGFloat(dt) // Float up + updated.scale = 1.0 + (0.2 * progress) // Grow slightly + } + + return updated + } + } +} + +// MARK: - Effect Views + +struct EffectOverlayView: View { + let effects: [EffectManager.ActiveEffect] + let camX: CGFloat + let camY: CGFloat + let zoom: CGFloat + let viewportSize: CGSize + + var body: some View { + ZStack { + ForEach(effects) { effect in + let screenX = (effect.x - camX) * zoom + let screenY = (effect.y - camY) * zoom + let screenMargin: CGFloat = 42 + if screenX >= -screenMargin && + screenX <= viewportSize.width + screenMargin && + screenY >= -screenMargin && + screenY <= viewportSize.height + screenMargin { + Group { + switch effect.kind { + case .particle(let color, _): + Rectangle() + .fill(color) + .frame(width: 4 * zoom * effect.scale, height: 4 * zoom * effect.scale) + case .floatingText(let text, let color): + Text(text.uppercased()) + .font(.system(size: 10 * zoom, weight: .heavy, design: .rounded)) + .foregroundStyle(color) + .shadow(color: .black, radius: 2, x: 1, y: 1) + } + } + .opacity(effect.opacity) + .position( + x: screenX, + y: screenY + ) + } + } + } + .allowsHitTesting(false) + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameSprites.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameSprites.swift new file mode 100644 index 00000000000..20cad3bdd76 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameSprites.swift @@ -0,0 +1,907 @@ +import SwiftUI +import Observation +import SpacetimeDB +#if canImport(AppKit) +import AppKit +#endif + +private let gameplayZoom: CGFloat = 1.9 +private let baseEntitySpriteSize: CGFloat = 58 + +fileprivate struct VisiblePlayerSnapshot: Identifiable { + let id: UInt64 + let player: Player + let worldX: Float + let worldY: Float + let direction: NinjaGameViewModel.NinjaDirection + let isMoving: Bool + let isFlashing: Bool + let color: Color +} + +// MARK: - Sword orbit layout + +/// Returns 2D offsets for `count` swords arranged in concentric rings. +/// +/// Ring packing: the innermost ring (radius 30) holds as many swords as fit +/// with at least 28pt of arc spacing between them. Each subsequent ring is +/// 25pt further out and holds proportionally more swords. All rings rotate +/// together at ~2 s per revolution. +@inline(__always) +func forEachSwordPosition(count: Int, t: TimeInterval, _ body: (CGPoint) -> Void) { + guard count > 0 else { return } + + @inline(__always) + func capacity(_ radius: CGFloat) -> Int { + max(1, Int((2 * .pi * radius) / 28)) + } + + var rings: [(radius: CGFloat, cap: Int)] = [] + rings.reserveCapacity(4) + var slots = 0 + var r: CGFloat = 50 + while slots < count { + let cap = capacity(r) + rings.append((r, cap)) + slots += cap + r += 25 + } + + var remaining = count + let baseAngle = t * .pi + + for ring in rings { + guard remaining > 0 else { break } + let inThisRing = min(remaining, ring.cap) + remaining -= inThisRing + for i in 0.. [CGPoint] { + var positions: [CGPoint] = [] + positions.reserveCapacity(max(0, count)) + forEachSwordPosition(count: count, t: t) { positions.append($0) } + return positions +} + +struct SwiftUIGameViewport: View { + let vm: NinjaGameViewModel + @State private var lastFrameTimestamp: TimeInterval? + @State private var frameMs: Double = 0 + private static let showPerfHUD = ProcessInfo.processInfo.environment["NINJA_PERF_HUD"] == "1" + private static let renderInterval: TimeInterval = 1.0 / 120.0 + + var body: some View { + GeometryReader { geo in + TimelineView(.animation(minimumInterval: Self.renderInterval, paused: false)) { timeline in + let t = timeline.date.timeIntervalSinceReferenceDate + let showPerfHUD = Self.showPerfHUD + let worldViewportSize = CGSize( + width: geo.size.width / gameplayZoom, + height: geo.size.height / gameplayZoom + ) + let camera = cameraOrigin(viewportSize: worldViewportSize) + let camX = camera.x + let camY = camera.y + let activeEffects = EffectManager.shared.activeEffects + let visibleWeapons = vm.weapons.filter { weapon in + isWorldPointVisible( + x: CGFloat(weapon.x), + y: CGFloat(weapon.y), + camX: camX, + camY: camY, + viewportWorldSize: worldViewportSize, + padding: 42 + ) + } + let visiblePlayers = vm.renderPlayers.compactMap { player -> VisiblePlayerSnapshot? in + let worldX: Float = { + if player.id == vm.userId && vm.hasJoined { return vm.localX } + return vm.smoothedPositions[player.id]?.x ?? player.x + }() + let worldY: Float = { + if player.id == vm.userId && vm.hasJoined { return vm.localY } + return vm.smoothedPositions[player.id]?.y ?? player.y + }() + guard isWorldPointVisible( + x: CGFloat(worldX), + y: CGFloat(worldY), + camX: camX, + camY: camY, + viewportWorldSize: worldViewportSize, + padding: 86 + ) else { + return nil + } + return VisiblePlayerSnapshot( + id: player.id, + player: player, + worldX: worldX, + worldY: worldY, + direction: vm.playerDirections[player.id] ?? .south, + isMoving: vm.playerIsMoving[player.id] ?? false, + isFlashing: vm.playerIsHitFlashing(player.id, at: t), + color: Color.fromId(player.id) + ) + } + let visibleEffectsCount = showPerfHUD + ? countVisibleEffects( + effects: activeEffects, + camX: camX, + camY: camY, + zoom: gameplayZoom, + viewportSize: geo.size + ) + : 0 + + ZStack { + ProceduralWorldBackdrop( + camX: camX, + camY: camY, + zoom: gameplayZoom + ) + + GameEntitiesCanvas( + players: visiblePlayers, + weapons: visibleWeapons, + t: t, + camX: camX, + camY: camY, + zoom: gameplayZoom + ) + + ForEach(visiblePlayers) { snapshot in + PlayerLabelsView(player: snapshot.player) + .position( + x: (CGFloat(snapshot.worldX) - camX) * gameplayZoom, + y: (CGFloat(snapshot.worldY) - camY) * gameplayZoom - 46 * gameplayZoom + ) + } + + EffectOverlayView( + effects: activeEffects, + camX: camX, + camY: camY, + zoom: gameplayZoom, + viewportSize: geo.size + ) + + if showPerfHUD { + VStack(alignment: .leading, spacing: 4) { + Text(String(format: "frame %.1f ms", frameMs)) + Text("players vis \(visiblePlayers.count) / total \(vm.players.count)") + Text("weapons vis \(visibleWeapons.count) / total \(vm.weapons.count)") + Text("effects vis \(visibleEffectsCount) / total \(activeEffects.count)") + Text("collision \(vm.isCollisionComputeInFlight ? "busy" : "idle")") + } + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundStyle(Color(red: 0.66, green: 0.96, blue: 0.90)) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.black.opacity(0.62)) + .overlay(Rectangle().strokeBorder(Color(red: 0.25, green: 0.80, blue: 0.78).opacity(0.9), lineWidth: 1)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.top, 72) + .padding(.trailing, 10) + } + } + .onChange(of: t) { _, _ in + let dt = max(0, min(0.05, t - (lastFrameTimestamp ?? t))) + EffectManager.shared.update(dt: dt, now: t) + if let last = lastFrameTimestamp { + frameMs = max(0, (t - last) * 1000.0) + } + lastFrameTimestamp = t + } + .frame( + width: geo.size.width, + height: geo.size.height, + alignment: .topLeading + ) + .clipped() + .allowsHitTesting(false) + } + } + } + + private func cameraOrigin(viewportSize: CGSize) -> CGPoint { + let anchorX: CGFloat = vm.hasJoined ? CGFloat(vm.localX) : CGFloat((worldMin + worldMax) * 0.5) + let anchorY: CGFloat = vm.hasJoined ? CGFloat(vm.localY) : CGFloat((worldMin + worldMax) * 0.5) + let worldMinCG = CGFloat(worldMin) + let worldMaxCG = CGFloat(worldMax) + let viewHalfW = viewportSize.width * 0.5 + let viewHalfH = viewportSize.height * 0.5 + let softPadX = min(viewHalfW * 0.65, 140) + let softPadY = min(viewHalfH * 0.65, 120) + + let minCamX = worldMinCG - softPadX + let maxCamX = worldMaxCG - viewportSize.width + softPadX + let minCamY = worldMinCG - softPadY + let maxCamY = worldMaxCG - viewportSize.height + softPadY + + let camX = clamp(anchorX - viewHalfW, min: minCamX, max: maxCamX) + let camY = clamp(anchorY - viewHalfH, min: minCamY, max: maxCamY) + return CGPoint(x: camX, y: camY) + } + + private func isWorldPointVisible( + x: CGFloat, + y: CGFloat, + camX: CGFloat, + camY: CGFloat, + viewportWorldSize: CGSize, + padding: CGFloat + ) -> Bool { + x >= camX - padding && + x <= camX + viewportWorldSize.width + padding && + y >= camY - padding && + y <= camY + viewportWorldSize.height + padding + } + + private func countVisibleEffects( + effects: [EffectManager.ActiveEffect], + camX: CGFloat, + camY: CGFloat, + zoom: CGFloat, + viewportSize: CGSize + ) -> Int { + let screenMargin: CGFloat = 42 + var count = 0 + for effect in effects { + let screenX = (effect.x - camX) * zoom + let screenY = (effect.y - camY) * zoom + if screenX >= -screenMargin && + screenX <= viewportSize.width + screenMargin && + screenY >= -screenMargin && + screenY <= viewportSize.height + screenMargin { + count += 1 + } + } + return count + } +} + +private struct ProceduralWorldBackdrop: View { + let camX: CGFloat + let camY: CGFloat + let zoom: CGFloat + + var body: some View { + Canvas(rendersAsynchronously: true) { ctx, size in + let bgRect = CGRect(origin: .zero, size: size) + ctx.fill( + Path(bgRect), + with: .linearGradient( + Gradient(stops: [ + .init(color: Color(red: 0.09, green: 0.06, blue: 0.16), location: 0.0), + .init(color: Color(red: 0.05, green: 0.03, blue: 0.10), location: 0.45), + .init(color: Color(red: 0.02, green: 0.02, blue: 0.06), location: 1.0), + ]), + startPoint: CGPoint(x: size.width * 0.5, y: 0), + endPoint: CGPoint(x: size.width * 0.5, y: size.height) + ) + ) + + let worldRect = CGRect( + x: CGFloat(worldMin), + y: CGFloat(worldMin), + width: CGFloat(worldMax - worldMin), + height: CGFloat(worldMax - worldMin) + ) + let tile: CGFloat = 32 + let worldViewportWidth = size.width / zoom + let worldViewportHeight = size.height / zoom + + let minTileX = Int(floor(camX / tile)) + let maxTileX = Int(ceil((camX + worldViewportWidth) / tile)) + let minTileY = Int(floor(camY / tile)) + let maxTileY = Int(ceil((camY + worldViewportHeight) / tile)) + + var lightTiles = Path() + for ty in minTileY...maxTileY { + for tx in minTileX...maxTileX where (tx + ty).isMultiple(of: 2) { + let r = CGRect( + x: (CGFloat(tx) * tile - camX) * zoom, + y: (CGFloat(ty) * tile - camY) * zoom, + width: tile * zoom, + height: tile * zoom + ) + lightTiles.addRect(r) + } + } + ctx.fill(lightTiles, with: .color(Color(red: 0.10, green: 0.08, blue: 0.18).opacity(0.80))) + + var minorGrid = Path() + var majorGrid = Path() + for tx in minTileX...maxTileX { + let x = (CGFloat(tx) * tile - camX) * zoom + if tx.isMultiple(of: 4) { + majorGrid.move(to: CGPoint(x: x, y: 0)) + majorGrid.addLine(to: CGPoint(x: x, y: size.height)) + } else { + minorGrid.move(to: CGPoint(x: x, y: 0)) + minorGrid.addLine(to: CGPoint(x: x, y: size.height)) + } + } + for ty in minTileY...maxTileY { + let y = (CGFloat(ty) * tile - camY) * zoom + if ty.isMultiple(of: 4) { + majorGrid.move(to: CGPoint(x: 0, y: y)) + majorGrid.addLine(to: CGPoint(x: size.width, y: y)) + } else { + minorGrid.move(to: CGPoint(x: 0, y: y)) + minorGrid.addLine(to: CGPoint(x: size.width, y: y)) + } + } + ctx.stroke(minorGrid, with: .color(Color(red: 0.18, green: 0.14, blue: 0.30).opacity(0.42)), lineWidth: 1) + ctx.stroke(majorGrid, with: .color(Color(red: 0.28, green: 0.24, blue: 0.44).opacity(0.65)), lineWidth: 1.5) + + ctx.fill( + Path(bgRect), + with: .radialGradient( + Gradient(stops: [ + .init(color: Color(red: 0.30, green: 0.20, blue: 0.45).opacity(0.20), location: 0.0), + .init(color: .clear, location: 1.0), + ]), + center: CGPoint(x: size.width * 0.5, y: size.height * 0.44), + startRadius: 0, + endRadius: max(size.width, size.height) * 0.6 + ) + ) + + let borderRect = CGRect( + x: (worldRect.minX - camX) * zoom, + y: (worldRect.minY - camY) * zoom, + width: worldRect.width * zoom, + height: worldRect.height * zoom + ) + ctx.stroke( + Path(borderRect), + with: .color(Color(red: 0.35, green: 0.78, blue: 1.0).opacity(0.35)), + lineWidth: 6 + ) + ctx.stroke( + Path(borderRect), + with: .color(Color(red: 0.60, green: 0.86, blue: 1.0).opacity(0.85)), + lineWidth: 2.5 + ) + } + } +} + +private func clamp(_ value: CGFloat, min minValue: CGFloat, max maxValue: CGFloat) -> CGFloat { + guard maxValue >= minValue else { return minValue } + return Swift.max(minValue, Swift.min(maxValue, value)) +} + + +// MARK: - Subviews for rendering entities + +private struct GameEntitiesCanvas: View { + let players: [VisiblePlayerSnapshot] + let weapons: [WeaponDrop] + let t: TimeInterval + let camX: CGFloat + let camY: CGFloat + let zoom: CGFloat + + var body: some View { + Canvas(rendersAsynchronously: true) { ctx, _ in + for weapon in weapons { + let center = CGPoint( + x: (CGFloat(weapon.x) - camX) * zoom, + y: (CGFloat(weapon.y) - camY) * zoom + ) + drawSword(in: &ctx, center: center, scale: zoom * 0.72, rotationDegrees: 12, glow: true) + } + + for snapshot in players { + let center = CGPoint( + x: (CGFloat(snapshot.worldX) - camX) * zoom, + y: (CGFloat(snapshot.worldY) - camY) * zoom + ) + drawNinja( + in: &ctx, + center: center, + direction: snapshot.direction, + isMoving: snapshot.isMoving, + hitFlash: snapshot.isFlashing, + t: t, + scale: zoom, + baseColor: snapshot.color, + lowHealth: snapshot.player.health < 33 + ) + if snapshot.player.weaponCount > 0 { + forEachSwordPosition(count: Int(snapshot.player.weaponCount), t: t) { offset in + let orbitCenter = CGPoint( + x: center.x + offset.x * zoom, + y: center.y + offset.y * zoom + ) + drawSword( + in: &ctx, + center: orbitCenter, + scale: zoom * 0.72, + rotationDegrees: -35, + glow: false + ) + } + } + } + } + } + + private func drawNinja( + in ctx: inout GraphicsContext, + center: CGPoint, + direction: NinjaGameViewModel.NinjaDirection, + isMoving: Bool, + hitFlash: Bool, + t: TimeInterval, + scale: CGFloat, + baseColor: Color, + lowHealth: Bool + ) { + let sprite = baseEntitySpriteSize * scale + let w = sprite + let h = sprite + let origin = CGPoint(x: center.x - w * 0.5, y: center.y - h * 0.5) + let tAdjusted = t * 1.5 + let bob = isMoving ? CGFloat(sin(tAdjusted * 4.0)) * 1.2 * scale : CGFloat(sin(tAdjusted * 1.6)) * 0.9 * scale + let swing = isMoving ? CGFloat(sin(tAdjusted * 8.0)) * 3.5 * scale : CGFloat(sin(tAdjusted * 1.8)) * 0.8 * scale + let legSwing = isMoving ? CGFloat(sin(tAdjusted * 8.0 + .pi / 2.0)) * 2.8 * scale : 0 + let top = origin.y + h * 0.10 + bob + + let primary = hitFlash ? Color.white : baseColor.opacity(lowHealth ? 0.92 : 1.0) + let dark = hitFlash ? Color.white : Color(red: 0.04, green: 0.05, blue: 0.10) + let hood = hitFlash ? Color.white : Color(red: 0.06, green: 0.08, blue: 0.14) + let accent = hitFlash ? Color.white : Color(red: 0.85, green: 0.12, blue: 0.18) + let skin = hitFlash ? Color.white : Color(red: 0.98, green: 0.82, blue: 0.72) + let eye = hitFlash ? Color.white : Color(red: 0.60, green: 0.85, blue: 1.0) + let facingEast = direction != .west + + func x(_ ratio: CGFloat) -> CGFloat { origin.x + ratio * w } + func y(_ ratio: CGFloat) -> CGFloat { top + ratio * h } + + func tint(_ color: Color) -> Color { + if hitFlash { + return Color.white + } + return lowHealth ? color.opacity(0.85) : color + } + + func fill(_ rect: CGRect, _ color: Color) { + ctx.fill(Path(rect), with: .color(tint(color))) + } + + let shadow = CGRect(x: x(0.22), y: y(0.75), width: w * 0.56, height: h * 0.10) + ctx.fill(Path(ellipseIn: shadow), with: .color(Color.black.opacity(0.35))) + + // Head + mask + fill(CGRect(x: x(0.30), y: y(-0.10), width: w * 0.40, height: h * 0.23), hood) + fill(CGRect(x: x(0.28), y: y(-0.02), width: w * 0.44, height: h * 0.06), accent) + fill(CGRect(x: x(0.30), y: y(0.03), width: w * 0.40, height: h * 0.10), hood) + if direction == .north { + fill(CGRect(x: x(0.35), y: y(0.05), width: w * 0.30, height: h * 0.02), eye.opacity(0.35)) + } else if facingEast { + fill(CGRect(x: x(0.50), y: y(0.04), width: w * 0.18, height: h * 0.04), skin) + fill(CGRect(x: x(0.60), y: y(0.032), width: w * 0.09, height: h * 0.046), eye.opacity(0.40)) + } else { + fill(CGRect(x: x(0.34), y: y(0.04), width: w * 0.32, height: h * 0.05), skin) + fill(CGRect(x: x(0.36), y: y(0.032), width: w * 0.10, height: h * 0.046), eye.opacity(0.38)) + fill(CGRect(x: x(0.54), y: y(0.032), width: w * 0.10, height: h * 0.046), eye.opacity(0.38)) + } + + // Torso + belt + fill(CGRect(x: x(0.28), y: y(0.13), width: w * 0.44, height: h * 0.38), primary) + fill(CGRect(x: x(0.27), y: y(0.44), width: w * 0.46, height: h * 0.07), dark) + fill(CGRect(x: x(0.38), y: y(0.44), width: w * 0.15, height: h * 0.07), accent) + + // Arms + fill(CGRect(x: x(0.16) - swing, y: y(0.15), width: w * 0.13, height: h * 0.28), primary) + fill(CGRect(x: x(0.71) + swing - w * 0.13, y: y(0.15), width: w * 0.13, height: h * 0.28), primary) + fill(CGRect(x: x(0.16) - swing, y: y(0.43), width: w * 0.10, height: h * 0.06), skin) + fill(CGRect(x: x(0.74) + swing - w * 0.10, y: y(0.43), width: w * 0.10, height: h * 0.06), skin) + + // Legs + boots + fill(CGRect(x: x(0.31) - legSwing, y: y(0.51), width: w * 0.15, height: h * 0.25), primary) + fill(CGRect(x: x(0.54) + legSwing, y: y(0.51), width: w * 0.15, height: h * 0.25), primary) + fill(CGRect(x: x(0.28) - legSwing, y: y(0.74), width: w * 0.20, height: h * 0.07), dark) + fill(CGRect(x: x(0.52) + legSwing, y: y(0.74), width: w * 0.20, height: h * 0.07), dark) + } + + private func drawSword( + in ctx: inout GraphicsContext, + center: CGPoint, + scale: CGFloat, + rotationDegrees: Double, + glow: Bool + ) { + let size = 56 * scale + let w = size + let h = size + let origin = CGPoint(x: center.x - w * 0.5, y: center.y - h * 0.5) + let angle = CGFloat(rotationDegrees * .pi / 180.0) + let c = cos(angle) + let s = sin(angle) + + func rotatePoint(_ point: CGPoint) -> CGPoint { + let dx = point.x - center.x + let dy = point.y - center.y + return CGPoint( + x: center.x + dx * c - dy * s, + y: center.y + dx * s + dy * c + ) + } + + func rotatedRectPath(_ rect: CGRect) -> Path { + var p = Path() + let a = rotatePoint(CGPoint(x: rect.minX, y: rect.minY)) + let b = rotatePoint(CGPoint(x: rect.maxX, y: rect.minY)) + let c = rotatePoint(CGPoint(x: rect.maxX, y: rect.maxY)) + let d = rotatePoint(CGPoint(x: rect.minX, y: rect.maxY)) + p.move(to: a) + p.addLine(to: b) + p.addLine(to: c) + p.addLine(to: d) + p.closeSubpath() + return p + } + + let blade = CGRect(x: origin.x + w * 0.47, y: origin.y + h * 0.14, width: w * 0.08, height: h * 0.56) + let edge = CGRect(x: origin.x + w * 0.52, y: origin.y + h * 0.16, width: w * 0.02, height: h * 0.52) + let guardRect = CGRect(x: origin.x + w * 0.40, y: origin.y + h * 0.66, width: w * 0.22, height: h * 0.06) + let grip = CGRect(x: origin.x + w * 0.46, y: origin.y + h * 0.71, width: w * 0.10, height: h * 0.15) + let pommel = CGRect(x: origin.x + w * 0.44, y: origin.y + h * 0.85, width: w * 0.14, height: h * 0.06) + + if glow { + let glowRect = CGRect(x: origin.x + w * 0.26, y: origin.y + h * 0.78, width: w * 0.48, height: h * 0.12) + ctx.fill(Path(ellipseIn: glowRect), with: .color(Color(red: 0.45, green: 0.82, blue: 1.0).opacity(0.25))) + } + + ctx.fill(rotatedRectPath(blade), with: .color(Color(red: 0.82, green: 0.90, blue: 1.0))) + ctx.fill(rotatedRectPath(edge), with: .color(.white)) + ctx.fill(rotatedRectPath(guardRect), with: .color(Color(red: 0.90, green: 0.72, blue: 0.22))) + ctx.fill(rotatedRectPath(grip), with: .color(Color(red: 0.25, green: 0.13, blue: 0.06))) + ctx.fill(rotatedRectPath(pommel), with: .color(Color(red: 0.76, green: 0.58, blue: 0.15))) + } +} + +struct PlayerEntityView: View { + let player: Player + let vm: NinjaGameViewModel + let t: TimeInterval + let camX: CGFloat + let camY: CGFloat + let zoom: CGFloat + + @State private var hitFlashTime: TimeInterval = 0 + + var body: some View { + // Render local player from predicted local position, others from smoothed interpolation. + let worldX: Float = { + if player.id == vm.userId && vm.hasJoined { return vm.localX } + return vm.smoothedPositions[player.id]?.x ?? player.x + }() + let worldY: Float = { + if player.id == vm.userId && vm.hasJoined { return vm.localY } + return vm.smoothedPositions[player.id]?.y ?? player.y + }() + + let px = (CGFloat(worldX) - camX) * zoom + let py = (CGFloat(worldY) - camY) * zoom + + // Render a fully procedural ninja (no texture assets). + let direction = vm.playerDirections[player.id] ?? .south + let isMoving = vm.playerIsMoving[player.id] ?? false + let isFlashing = t - hitFlashTime < 0.15 + let baseColor = Color.fromId(player.id) + + playerSprite(direction: direction, isMoving: isMoving, t: t, isFlashing: isFlashing, color: baseColor) + .shadow(color: Color.black.opacity(0.35), radius: 3, x: 0, y: 2) + .colorMultiply(player.health < 33 ? Color.red.opacity(0.8) : Color.white) + .onChange(of: player.health) { oldHealth, newHealth in + if newHealth < oldHealth && newHealth > 0 { + hitFlashTime = t + } + } + .overlay(alignment: .top) { + PlayerLabelsView(player: player) + .offset(y: -46 * zoom) + } + .overlay { + let swords = swordPositions(count: Int(player.weaponCount), t: t) + ForEach(0.. some View { + ProceduralNinjaSpriteView( + direction: direction, + isMoving: isMoving, + t: t, + spriteSize: CGSize(width: 58 * zoom, height: 58 * zoom), + hitFlash: isFlashing, + baseColor: color + ) + } +} + +struct PlayerLabelsView: View { + let player: Player + + var body: some View { + VStack(spacing: 2) { + if player.kills > 0 { + Text("★ \(player.kills)") + .font(.system(size: 9, weight: .heavy, design: .rounded)) + .foregroundStyle(SurvivorsTheme.accent) + } + Text(player.name.uppercased()) + .font(.system(size: 9, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + + HealthBar(health: player.health) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color(red: 0.06, green: 0.05, blue: 0.12).opacity(0.90)) + .overlay(Rectangle().strokeBorder(Color(white: 0.35), lineWidth: 1)) + .fixedSize() + } +} + +struct HealthBar: View { + let health: UInt32 + + private var healthFraction: CGFloat { CGFloat(min(100, health)) / 100.0 } + private var barColor: Color { + if health > 60 { return Color(red: 0.10, green: 0.90, blue: 0.20) } + if health > 30 { return Color(red: 1.00, green: 0.75, blue: 0.00) } + return Color(red: 0.95, green: 0.15, blue: 0.15) + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Rectangle().fill(Color.black.opacity(0.60)) + Rectangle() + .fill(barColor) + .frame(width: max(2, geo.size.width * healthFraction)) + } + } + .frame(width: 38, height: 4) + .overlay(Rectangle().strokeBorder(Color(white: 0.30), lineWidth: 1)) + .padding(.top, 1) + } +} + +struct WeaponEntityView: View { + let weapon: WeaponDrop + let camX: CGFloat + let camY: CGFloat + let zoom: CGFloat + + var body: some View { + ProceduralSwordSpriteView( + spriteSize: CGSize(width: 56 * zoom, height: 56 * zoom), + style: .ground + ) + .scaleEffect(0.72) + .position( + x: (CGFloat(weapon.x) - camX) * zoom, + y: (CGFloat(weapon.y) - camY) * zoom + ) + } +} + +// MARK: - Native Sprite Rendering + +struct ProceduralNinjaSpriteView: View { + let direction: NinjaGameViewModel.NinjaDirection + let isMoving: Bool + let t: TimeInterval + let spriteSize: CGSize + let hitFlash: Bool + let baseColor: Color + + var body: some View { + ZStack { + // Scarf rendered in a 4× wider canvas so the trail is never clipped. + Canvas { ctx, size in + guard size.width > 0, size.height > 0 else { return } + let sw = size.width + let h = size.height + let bw = sw / 4.0 // logical body width + let bodyLeft = (sw - bw) / 2.0 // body left edge in scarf-canvas space + let tAdjusted = t * 1.5 + let bob = isMoving ? CGFloat(sin(tAdjusted * 4.0)) * 1.2 : CGFloat(sin(tAdjusted * 1.6)) * 0.9 + let top = h * 0.10 + bob + + let scarfRed = hitFlash ? Color.white : Color(red: 0.85, green: 0.12, blue: 0.18) + // Trail direction: negative = left (east/south/north), positive = right (west) + let trailSign: CGFloat = direction == .west ? 1.0 : -1.0 + let trailX = trailSign * (isMoving ? 70.0 : 30.0) + let waveFreq = isMoving ? 10.0 : 3.5 + let waveAmp = isMoving ? 13.0 : 9.0 + + var scarf = Path() + let scarfBase = CGPoint(x: bodyLeft + bw * 0.45, y: top + h * 0.22) + scarf.move(to: scarfBase) + for i in 1...8 { + let seg = Double(i) / 8.0 + let px = scarfBase.x + CGFloat(seg * Double(trailX) * Double(bw) * 0.01) + let py = scarfBase.y + CGFloat(sin(tAdjusted * waveFreq + seg * 6.0) * waveAmp * CGFloat(seg)) + scarf.addLine(to: CGPoint(x: px, y: py)) + } + ctx.stroke(scarf, with: .color(scarfRed), + style: StrokeStyle(lineWidth: 5 * (bw / 58), lineCap: .round, lineJoin: .round)) + ctx.stroke(scarf, with: .color(Color(red: 1.0, green: 0.55, blue: 0.60).opacity(0.45)), + style: StrokeStyle(lineWidth: 1.5 * (bw / 58), lineCap: .round, lineJoin: .round)) + } + .frame(width: spriteSize.width * 4.0, height: spriteSize.height) + + Canvas(rendersAsynchronously: true) { ctx, size in + guard size.width > 0, size.height > 0 else { return } + + var g = ctx + var facing = direction + if direction == .west { + g.translateBy(x: size.width, y: 0) + g.scaleBy(x: -1, y: 1) + facing = .east + } + + let w = size.width + let h = size.height + let tAdjusted = t * 1.5 // Speed up for crisper anims + let swing = isMoving ? CGFloat(sin(tAdjusted * 8.0)) * 3.5 : CGFloat(sin(tAdjusted * 1.8)) * 0.8 + let legSwing = isMoving ? CGFloat(sin(tAdjusted * 8.0 + .pi / 2.0)) * 2.8 : 0 + let bob = isMoving ? CGFloat(sin(tAdjusted * 4.0)) * 1.2 : CGFloat(sin(tAdjusted * 1.6)) * 0.9 + let top = h * 0.10 + bob + + // 1. Ambient Shadow + let shadow = CGRect(x: w * 0.22, y: h * 0.85, width: w * 0.56, height: h * 0.10) + g.fill(Path(ellipseIn: shadow), with: .color(Color.black.opacity(0.35))) + + // 3. Color Logic + let primary = hitFlash ? Color.white : baseColor + let highlight = hitFlash ? Color.white : primary.opacity(0.7) + let dark = hitFlash ? Color.white : Color(red: 0.04, green: 0.05, blue: 0.10) + let hoodColor = hitFlash ? Color.white : Color(red: 0.06, green: 0.08, blue: 0.14) + let accentColor = hitFlash ? Color.white : Color(red: 0.85, green: 0.12, blue: 0.18) + let skinColor = hitFlash ? Color.white : Color(red: 0.98, green: 0.82, blue: 0.72) + let eyeGlow = hitFlash ? Color.white : Color(red: 0.60, green: 0.85, blue: 1.0) + + func fillRect(_ x: CGFloat, _ y: CGFloat, _ width: CGFloat, _ height: CGFloat, _ color: Color) { + g.fill(Path(CGRect(x: x, y: y, width: width, height: height)), with: .color(color)) + } + + // 4. Head Remastered + // Hood peak + var headPath = Path() + headPath.move(to: CGPoint(x: w * 0.30, y: top + h * 0.23)) + headPath.addLine(to: CGPoint(x: w * 0.50, y: top - h * 0.02)) // Peak + headPath.addLine(to: CGPoint(x: w * 0.70, y: top + h * 0.23)) + g.fill(headPath, with: .color(hoodColor)) + + fillRect(w * 0.30, top, w * 0.40, h * 0.23, hoodColor) + fillRect(w * 0.45, top + h * 0.01, w * 0.10, h * 0.10, highlight.opacity(0.09)) // Hood center highlight + fillRect(w * 0.31, top + h * 0.02, w * 0.38, h * 0.05, highlight.opacity(0.4)) // Peak hilight + fillRect(w * 0.28, top + h * 0.08, w * 0.44, h * 0.06, accentColor) // Headband + fillRect(w * 0.30, top + h * 0.09, w * 0.10, h * 0.02, Color.white.opacity(0.22)) // Headband glint + fillRect(w * 0.30, top + h * 0.13, w * 0.40, h * 0.10, hoodColor) // Bottom face + fillRect(w * 0.30, top + h * 0.21, w * 0.40, h * 0.02, dark.opacity(0.28)) // Hood bottom shadow + + if facing == .north { + fillRect(w * 0.35, top + h * 0.15, w * 0.30, h * 0.02, highlight.opacity(0.2)) + } else if facing == .east { + fillRect(w * 0.50, top + h * 0.14, w * 0.18, h * 0.04, skinColor) + fillRect(w * 0.60, top + h * 0.132, w * 0.09, h * 0.046, eyeGlow.opacity(0.40)) // Eye glow + fillRect(w * 0.62, top + h * 0.14, w * 0.05, h * 0.03, dark) // Eye + } else { + // Front eyes & skin + fillRect(w * 0.34, top + h * 0.14, w * 0.32, h * 0.05, skinColor) + fillRect(w * 0.36, top + h * 0.132, w * 0.10, h * 0.046, eyeGlow.opacity(0.38)) // Left eye glow + fillRect(w * 0.54, top + h * 0.132, w * 0.10, h * 0.046, eyeGlow.opacity(0.38)) // Right eye glow + fillRect(w * 0.38, top + h * 0.14, w * 0.06, h * 0.03, dark) // Left eye + fillRect(w * 0.56, top + h * 0.14, w * 0.06, h * 0.03, dark) // Right eye + } + + // 5. Torso & Sash + fillRect(w * 0.28, top + h * 0.23, w * 0.44, h * 0.38, primary) + fillRect(w * 0.47, top + h * 0.23, w * 0.06, h * 0.38, dark.opacity(0.4)) // Mid slit + fillRect(w * 0.28, top + h * 0.23, w * 0.09, h * 0.31, dark.opacity(0.22)) // Left torso shadow + fillRect(w * 0.63, top + h * 0.23, w * 0.09, h * 0.31, highlight.opacity(0.14)) // Right rim light + fillRect(w * 0.27, top + h * 0.54, w * 0.46, h * 0.07, dark) // Belt (Obi) + fillRect(w * 0.38, top + h * 0.54, w * 0.15, h * 0.07, accentColor) // Knot + fillRect(w * 0.27, top + h * 0.54, w * 0.46, h * 0.01, highlight.opacity(0.18)) // Belt top highlight + fillRect(w * 0.37, top + h * 0.61, w * 0.06, h * 0.03, accentColor.opacity(0.85)) // Knot left tail + fillRect(w * 0.47, top + h * 0.61, w * 0.06, h * 0.03, accentColor.opacity(0.85)) // Knot right tail + + // 6. Arms remastered + fillRect(w * 0.16 - swing, top + h * 0.25, w * 0.13, h * 0.28, primary) + fillRect(w * 0.71 + swing - w * 0.13, top + h * 0.25, w * 0.13, h * 0.28, primary) + fillRect(w * 0.16 - swing, top + h * 0.25, w * 0.04, h * 0.28, highlight.opacity(0.3)) // Rim light + // Forearm wraps + fillRect(w * 0.16 - swing, top + h * 0.37, w * 0.13, h * 0.017, dark.opacity(0.30)) + fillRect(w * 0.16 - swing, top + h * 0.43, w * 0.13, h * 0.017, dark.opacity(0.30)) + fillRect(w * 0.71 + swing - w * 0.13, top + h * 0.37, w * 0.13, h * 0.017, dark.opacity(0.30)) + fillRect(w * 0.71 + swing - w * 0.13, top + h * 0.43, w * 0.13, h * 0.017, dark.opacity(0.30)) + + fillRect(w * 0.16 - swing, top + h * 0.53, w * 0.10, h * 0.06, skinColor) // Hands + fillRect(w * 0.74 + swing - w * 0.10, top + h * 0.53, w * 0.10, h * 0.06, skinColor) + + // 7. Legs remastered + fillRect(w * 0.31 - legSwing, top + h * 0.61, w * 0.15, h * 0.25, primary) + fillRect(w * 0.54 + legSwing, top + h * 0.61, w * 0.15, h * 0.25, primary) + fillRect(w * 0.28 - legSwing, top + h * 0.84, w * 0.20, h * 0.07, dark) // Boots + fillRect(w * 0.52 + legSwing, top + h * 0.84, w * 0.20, h * 0.07, dark) + // Shin definition + fillRect(w * 0.37 - legSwing, top + h * 0.62, w * 0.03, h * 0.22, dark.opacity(0.22)) + fillRect(w * 0.60 + legSwing, top + h * 0.62, w * 0.03, h * 0.22, dark.opacity(0.22)) + // Boot toe highlights + fillRect(w * 0.31 - legSwing, top + h * 0.84, w * 0.08, h * 0.020, highlight.opacity(0.14)) + fillRect(w * 0.55 + legSwing, top + h * 0.84, w * 0.08, h * 0.020, highlight.opacity(0.14)) + } + .frame(width: spriteSize.width, height: spriteSize.height) + } + .frame(width: spriteSize.width, height: spriteSize.height) + .accessibilityLabel("Procedural ninja sprite") + } +} + +struct ProceduralSwordSpriteView: View { + enum Style { + case orbit + case ground + } + + let spriteSize: CGSize + let style: Style + + var body: some View { + Canvas(rendersAsynchronously: true) { ctx, size in + guard size.width > 0, size.height > 0 else { return } + + let w = size.width + let h = size.height + let blade = CGRect(x: w * 0.47, y: h * 0.14, width: w * 0.08, height: h * 0.56) + let edge = CGRect(x: w * 0.52, y: h * 0.16, width: w * 0.02, height: h * 0.52) + let guardRect = CGRect(x: w * 0.40, y: h * 0.66, width: w * 0.22, height: h * 0.06) + let grip = CGRect(x: w * 0.46, y: h * 0.71, width: w * 0.10, height: h * 0.15) + let pommel = CGRect(x: w * 0.44, y: h * 0.85, width: w * 0.14, height: h * 0.06) + + if style == .ground { + let glow = CGRect(x: w * 0.26, y: h * 0.78, width: w * 0.48, height: h * 0.12) + ctx.fill(Path(ellipseIn: glow), with: .color(Color(red: 0.45, green: 0.82, blue: 1.0).opacity(0.25))) + } + + ctx.fill(Path(blade), with: .color(Color(red: 0.82, green: 0.90, blue: 1.0))) + ctx.fill(Path(edge), with: .color(.white)) + ctx.fill(Path(guardRect), with: .color(Color(red: 0.90, green: 0.72, blue: 0.22))) + ctx.fill(Path(grip), with: .color(Color(red: 0.25, green: 0.13, blue: 0.06))) + ctx.fill(Path(pommel), with: .color(Color(red: 0.76, green: 0.58, blue: 0.15))) + } + .frame(width: spriteSize.width, height: spriteSize.height) + .rotationEffect(style == .orbit ? .degrees(-35) : .degrees(12)) + .accessibilityLabel("Procedural sword sprite") + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameUI.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameUI.swift new file mode 100644 index 00000000000..95e61fc29d8 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameUI.swift @@ -0,0 +1,206 @@ +import SwiftUI +import Observation +import SpacetimeDB +#if canImport(AppKit) +import AppKit +#endif + + +struct GameEventEntry: Identifiable { + enum Kind { + case info + case combat + } + + let id: Int + let text: String + let kind: Kind + let timestamp: Date +} + +struct HudStatChip: View { + let label: String + let value: String + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + Text(label) + .font(.system(size: 9, weight: .semibold, design: .rounded)) + .foregroundStyle(tint.opacity(0.72)) + Text(value) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(tint) + } + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(tint.opacity(0.10)) + .overlay(Rectangle().strokeBorder(tint.opacity(0.42), lineWidth: 2)) + } +} + +struct HudHealthMeter: View { + let health: UInt32 + + private var clampedHealth: Double { + min(100, max(0, Double(health))) + } + + private var healthFraction: Double { + clampedHealth / 100 + } + + private var healthColor: Color { + Color(hue: 0.33 * healthFraction, saturation: 0.82, brightness: 0.95) + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text("HP") + .font(.system(size: 9, weight: .heavy, design: .rounded)) + .foregroundStyle(healthColor.opacity(0.80)) + Text("\(Int(clampedHealth))/100") + .font(.system(size: 11, weight: .heavy, design: .rounded)) + .foregroundStyle(healthColor) + } + + GeometryReader { geo in + ZStack(alignment: .leading) { + Rectangle() + .fill(Color.black.opacity(0.35)) + Rectangle() + .fill(healthColor) + .frame(width: max(4, geo.size.width * healthFraction)) + } + } + .frame(height: 7) + .overlay(Rectangle().strokeBorder(healthColor.opacity(0.50), lineWidth: 1)) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Color.white.opacity(0.06)) + .overlay(Rectangle().strokeBorder(Color(white: 0.28), lineWidth: 1)) + } +} + +struct EventFeedView: View { + let events: [GameEventEntry] + var title: String = "Event Feed" + var maxVisible: Int = 8 + var padded: Bool = false + private let eventLifetime: TimeInterval = 18 + private let fadeDuration: TimeInterval = 10 + private let popInDuration: TimeInterval = 0.35 + + struct RenderedEvent: Identifiable { + let entry: GameEventEntry + let opacity: Double + let scale: CGFloat + let offsetY: CGFloat + var id: Int { entry.id } + } + + private func renderedEvents(at now: Date) -> [RenderedEvent] { + Array(events.suffix(maxVisible).reversed()).compactMap { event in + let age = now.timeIntervalSince(event.timestamp) + guard age >= 0, age < eventLifetime else { return nil } + + let fadeStart = eventLifetime - fadeDuration + let opacity: Double + if age <= fadeStart { + opacity = 1.0 + } else { + opacity = max(0, (eventLifetime - age) / max(0.001, fadeDuration)) + } + + let popProgress = min(1, max(0, age / popInDuration)) + let popEase = 1 - pow(1 - popProgress, 3) + let scale = 0.94 + (0.06 * popEase) + let offsetY = 8 * (1 - popEase) + + return RenderedEvent( + entry: event, + opacity: opacity, + scale: scale, + offsetY: offsetY + ) + } + } + + private var listHeight: CGFloat { + // Stable height prevents panel-edge jitter as items appear/disappear. + CGFloat(maxVisible) * 18 + 4 + } + + var body: some View { + TimelineView(.periodic(from: .now, by: 0.25)) { timeline in + let visible = renderedEvents(at: timeline.date) + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(Color(white: 0.72)) + .shadow(color: .black, radius: 2, x: 1, y: 1) + + ZStack(alignment: .topLeading) { + if visible.isEmpty { + Text("No recent events") + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(Color(white: 0.35)) + .shadow(color: .black.opacity(0.8), radius: 1, x: 1, y: 1) + } + + VStack(alignment: .leading, spacing: 4) { + ForEach(visible) { item in + HStack(spacing: 6) { + Text(item.entry.kind == .combat ? "►" : "·") + .font(.system(size: 9, weight: .heavy, design: .rounded)) + .foregroundStyle(item.entry.kind == .combat ? Color.orange : SurvivorsTheme.accent) + .shadow(color: .black.opacity(0.8), radius: 1, x: 0, y: 1) + Text(item.entry.text) + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .shadow(color: .black, radius: 1.5, x: 1, y: 1) + Spacer(minLength: 0) + } + .opacity(item.opacity) + .scaleEffect(item.scale, anchor: .leading) + .offset(y: item.offsetY) + } + } + } + .frame(height: listHeight, alignment: .topLeading) + } + .animation(.spring(response: 0.28, dampingFraction: 0.86), value: visible.map(\.id)) + .padding(padded ? 10 : 0) + } + } +} + +struct MenuButton: View { + let title: String + let systemImage: String + var role: ButtonRole? = nil + let action: () -> Void + + var body: some View { + Button(role: role, action: action) { + HStack(spacing: 8) { + Image(systemName: systemImage) + Text(title) + Spacer() + } + } + .buttonStyle(PixelButtonStyle(danger: role == .some(.destructive))) + .controlSize(.large) + .frame(maxWidth: .infinity) + } +} + +extension Color { + static func fromId(_ id: UInt64) -> Color { + let h = Double(id % 360) / 360.0 + return Color(hue: h, saturation: 0.72, brightness: 0.88) + } +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameView.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameView.swift new file mode 100644 index 00000000000..e4a3f27e7bb --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameView.swift @@ -0,0 +1,615 @@ +import SwiftUI +import Observation +import SpacetimeDB +#if canImport(AppKit) +import AppKit +#endif + +let worldMin: Float = 0 +let worldMax: Float = 1000 +let playerEdgePadding: Float = 18 +let weaponSpawnPadding: Float = 24 +let weaponSpawnInterval: TimeInterval = 2.8 +let maxGroundWeapons = 20 + +public enum ExitAction { + case resetName + case quit +} + +public enum SpacetimeEnvironment: String, CaseIterable, Identifiable { + case local = "Local Server" + case prod = "Prod DB" + + public var id: String { self.rawValue } + + public var url: URL { + switch self { + case .local: + return URL(string: "http://127.0.0.1:3000")! + case .prod: + return URL(string: "wss://maincloud.spacetimedb.com")! + } + } +} + +public struct NinjaGameView: View { + @State private var vm: NinjaGameViewModel + @State private var showingResetNameDialog = false + @State private var resetNameDraft = "" + private let ownsViewModel: Bool + let onExit: ((ExitAction) -> Void)? + var onMusicChange: ((Bool) -> Void)? // true = game music, false = title music + var onMuteToggle: (() -> Void)? + var isMuted: Bool + var isBackground: Bool + + /// Pass a name to auto-join immediately on appear. + public init( + isBackground: Bool = false, + initialName: String? = nil, + isMuted: Bool = false, + injectedVM: NinjaGameViewModel? = nil, + onMuteToggle: (() -> Void)? = nil, + onExit: ((ExitAction) -> Void)? = nil, + onMusicChange: ((Bool) -> Void)? = nil + ) { + if let injected = injectedVM { + _vm = State(initialValue: injected) + self.ownsViewModel = false + } else { + _vm = State(initialValue: NinjaGameViewModel(initialName: initialName)) + self.ownsViewModel = true + } + self.isBackground = isBackground + self.isMuted = isMuted + self.onMuteToggle = onMuteToggle + self.onExit = onExit + self.onMusicChange = onMusicChange + } + + private var isActivePlayState: Bool { + vm.hasJoined && !vm.isMenuOpen && !vm.isDead + } + + public var body: some View { + ZStack { + // Game Area — camera follows local player + GeometryReader { _ in + ZStack { + SwiftUIGameViewport(vm: vm) + .background( + LinearGradient( + colors: [SurvivorsTheme.backdropBottom, SurvivorsTheme.backdropTop], + startPoint: .top, + endPoint: .bottom + ) + ) + .clipped() + + #if !os(macOS) + if let base = vm.jsBase { + ZStack { + Circle() + .strokeBorder(Color.primary.opacity(0.28), lineWidth: 1) + .frame(width: 100, height: 100) + Circle() + .strokeBorder(Color.primary.opacity(0.65), lineWidth: 2) + .frame(width: 50, height: 50) + .offset(x: vm.jsVector.dx, y: vm.jsVector.dy) + } + .position(base) + } + #endif + } + + // HUD Layer Overlay + VStack(spacing: 0) { + if !isBackground { + statusBar + .padding(.top, 12) + .padding(.horizontal, 16) + } + + HStack { + if !isBackground { + EventFeedView(events: vm.recentEvents) + .padding(.top, 12) + .padding(.leading, 12) + } + Spacer() + } + + Spacer() + + if !isBackground { + playingFooter + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + } + } + #if !os(macOS) + .gesture( + DragGesture(minimumDistance: 5) + .onChanged { val in + vm.updateJoystick(active: true, base: val.startLocation, current: val.location) + } + .onEnded { _ in + vm.updateJoystick(active: false) + } + ) + #endif + } + .grayscale(vm.isDead ? 1.0 : 0.0) // B&W effect when dead + .overlay { + if !isBackground && vm.isDead { + ZStack { + Color.black.opacity(0.35) + .ignoresSafeArea() + + VStack(spacing: 16) { + Text("Eliminated") + .font(.system(size: 18, weight: .heavy, design: .rounded)) + .foregroundStyle(Color(red: 1.0, green: 0.35, blue: 0.35)) + + Text("Wait for an opening, then rejoin.") + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(Color(white: 0.45)) + .multilineTextAlignment(.center) + + Button(action: { + Respawn.invoke() + vm.isMenuOpen = false + SoundEffects.shared.play(.respawn) + }) { + HStack(spacing: 6) { + Image(systemName: "arrow.clockwise") + Text("Respawn") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(PixelButtonStyle(filled: true)) + .controlSize(.large) + } + .frame(width: 340) + .padding(.horizontal, 16) + .padding(.vertical, 18) + .pixelPanel() + .shadow(color: Color(red: 0.3, green: 0.6, blue: 1.0).opacity(0.20), radius: 24, x: 0, y: 8) + } + .transition(.opacity) + .ignoresSafeArea() + } + } + .overlay { + if !isBackground && vm.isMenuOpen { + ZStack { + Color.black.opacity(0.45) + .ignoresSafeArea() + .onTapGesture { + SoundEffects.shared.play(.menuClose) + showingResetNameDialog = false + vm.isMenuOpen = false + } + + VStack(spacing: 12) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text("Paused") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + Text(vm.myPlayer?.name ?? "Connected Player") + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(Color(white: 0.52)) + Text("Choose your next move.") + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(Color(white: 0.40)) + } + Spacer(minLength: 12) + Text("ESC • RESUME") + .font(.system(size: 10, weight: .heavy, design: .rounded)) + .foregroundStyle(Color(white: 0.42)) + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(Color.white.opacity(0.06)) + .overlay(Rectangle().strokeBorder(Color(white: 0.26), lineWidth: 1)) + } + + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + if let me = vm.myPlayer { + HudHealthMeter(health: me.health) + .frame(width: 148) + HudStatChip(label: "Kills", value: "\(me.kills)", tint: .orange) + HudStatChip(label: "Swords", value: "\(me.weaponCount)", tint: SurvivorsTheme.accent) + } + HudStatChip(label: "Players", value: "\(vm.players.count)", tint: .green) + } + + VStack(alignment: .leading, spacing: 8) { + if let me = vm.myPlayer { + HudHealthMeter(health: me.health) + .frame(maxWidth: .infinity, alignment: .leading) + HStack(spacing: 8) { + HudStatChip(label: "Kills", value: "\(me.kills)", tint: .orange) + HudStatChip(label: "Swords", value: "\(me.weaponCount)", tint: SurvivorsTheme.accent) + HudStatChip(label: "Players", value: "\(vm.players.count)", tint: .green) + } + } else { + HudStatChip(label: "Players", value: "\(vm.players.count)", tint: .green) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("SESSION") + .font(.system(size: 10, weight: .heavy, design: .rounded)) + .foregroundStyle(Color(white: 0.44)) + Spacer() + } + + if let lobby = vm.myLobby { + let count = vm.playerCount(forLobbyId: lobby.id) + let maxCount = NinjaGameViewModel.maxPlayersPerLobby + HStack(alignment: .firstTextBaseline) { + Text(lobby.name) + .font(.system(size: 14, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + Spacer(minLength: 8) + Text("ID #\(lobby.id)") + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundStyle(Color(white: 0.42)) + } + + HStack(spacing: 8) { + Text("\(count)/\(maxCount) players") + Text("·") + Text(lobby.isPlaying ? "Playing" : "Waiting") + } + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundStyle(count >= maxCount ? .red : Color(white: 0.50)) + } else { + Text("NO ACTIVE LOBBY") + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(Color(white: 0.40)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.white.opacity(0.06)) + .overlay(Rectangle().strokeBorder(Color(red: 0.55, green: 0.82, blue: 1.0).opacity(0.26), lineWidth: 2)) + + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + Button { + SoundEffects.shared.play(.menuClose) + showingResetNameDialog = false + vm.isMenuOpen = false + } label: { + Label("CONTINUE", systemImage: "play.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(PixelButtonStyle(filled: true)) + .keyboardShortcut(.defaultAction) + + Button { + SoundEffects.shared.play(.menuButton) + resetNameDraft = vm.myPlayer?.name ?? vm.initialName ?? "" + showingResetNameDialog = true + } label: { + Label("EDIT NAME", systemImage: "person.text.rectangle") + .frame(maxWidth: .infinity) + } + .buttonStyle(PixelButtonStyle()) + } + .controlSize(.large) + + VStack(spacing: 8) { + Button { + SoundEffects.shared.play(.menuClose) + showingResetNameDialog = false + vm.isMenuOpen = false + } label: { + Label("CONTINUE", systemImage: "play.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(PixelButtonStyle(filled: true)) + .controlSize(.large) + .keyboardShortcut(.defaultAction) + + Button { + SoundEffects.shared.play(.menuButton) + resetNameDraft = vm.myPlayer?.name ?? vm.initialName ?? "" + showingResetNameDialog = true + } label: { + Label("EDIT NAME", systemImage: "person.text.rectangle") + .frame(maxWidth: .infinity) + } + .buttonStyle(PixelButtonStyle()) + .controlSize(.regular) + } + } + + HStack { + Text("DANGER") + .font(.system(size: 10, weight: .heavy, design: .rounded)) + .foregroundStyle(Color(red: 1.0, green: 0.45, blue: 0.45)) + Spacer() + } + + HStack(spacing: 8) { + Button(role: .destructive) { + SoundEffects.shared.play(.menuButton) + LeaveLobby.invoke() + showingResetNameDialog = false + vm.isMenuOpen = false + } label: { + Label("LEAVE LOBBY", systemImage: "rectangle.portrait.and.arrow.right") + .frame(maxWidth: .infinity) + } + .buttonStyle(PixelButtonStyle(danger: true)) + .controlSize(.regular) + .disabled(vm.myLobby == nil) + + Button(role: .destructive) { + SoundEffects.shared.play(.menuButton) + EndMatch.invoke() + showingResetNameDialog = false + vm.isMenuOpen = false + } label: { + Label("END MATCH", systemImage: "flag.checkered") + .frame(maxWidth: .infinity) + } + .buttonStyle(PixelButtonStyle(danger: true)) + .controlSize(.regular) + .disabled(!vm.isPlaying) + } + + Button(role: .destructive) { + SoundEffects.shared.play(.menuButton) + showingResetNameDialog = false + vm.stop() + onExit?(.quit) + } label: { + Label("RETURN TO TITLE", systemImage: "xmark.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(PixelButtonStyle(danger: true)) + .controlSize(.regular) + + if showingResetNameDialog { + VStack(alignment: .leading, spacing: 8) { + Text("Edit Name") + .font(.system(size: 12, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + Text("Update your callsign without leaving this session.") + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(Color(white: 0.44)) + + TextField("NEW CALLSIGN", text: $resetNameDraft) + .textFieldStyle(.plain) + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.white.opacity(0.06)) + .overlay(Rectangle().strokeBorder(Color(red: 0.55, green: 0.82, blue: 1.0).opacity(0.40), lineWidth: 2)) + .onSubmit { + let trimmed = resetNameDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + SoundEffects.shared.play(.buttonPress) + vm.renameCurrentPlayer(to: trimmed) + showingResetNameDialog = false + } + + HStack(spacing: 8) { + Button("Back") { + SoundEffects.shared.play(.buttonPress) + showingResetNameDialog = false + } + .buttonStyle(PixelButtonStyle()) + + Button("Save") { + let trimmed = resetNameDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + SoundEffects.shared.play(.buttonPress) + vm.renameCurrentPlayer(to: trimmed) + showingResetNameDialog = false + } + .buttonStyle(PixelButtonStyle(filled: true)) + .disabled(resetNameDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.white.opacity(0.06)) + .overlay(Rectangle().strokeBorder(Color(red: 0.55, green: 0.82, blue: 1.0).opacity(0.26), lineWidth: 2)) + } + } + .frame(maxWidth: 560) + .padding(.horizontal, 16) + .padding(.vertical, 16) + .pixelPanel() + .shadow(color: Color(red: 0.3, green: 0.6, blue: 1.0).opacity(0.20), radius: 24, x: 0, y: 8) + } + .transition(.opacity.combined(with: .scale(scale: 0.98))) + } + } + .animation(.spring(duration: 0.3), value: vm.isMenuOpen) + .animation(.easeInOut(duration: 1.5), value: vm.isDead) // Smooth B&W transition + .onChange(of: vm.hasJoined) { _, _ in onMusicChange?(isActivePlayState) } + .onChange(of: vm.isDead) { _, _ in onMusicChange?(isActivePlayState) } + .onChange(of: vm.isMenuOpen) { _, _ in onMusicChange?(isActivePlayState) } + .onChange(of: isMuted) { _, newVal in SoundEffects.shared.isMuted = newVal } + .disabled(isBackground) + .onAppear { + vm.start() + onMusicChange?(isActivePlayState) + + if !isBackground { + #if canImport(AppKit) + NSApp.activate(ignoringOtherApps: true) + DispatchQueue.main.async { + NSApp.windows.first?.makeKeyAndOrderFront(nil) + } + vm.installKeyboardMonitor() + #endif + } + } + .onDisappear { + if !isBackground { + vm.uninstallKeyboardMonitor() + } + if ownsViewModel { + vm.stop() + } + } + } + + private var statusBar: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + HStack(spacing: 6) { + Rectangle() + .fill(vm.isConnected ? Color.green : Color.red) + .frame(width: 7, height: 7) + Text(vm.isConnected ? "ONLINE" : "OFFLINE") + .font(.system(size: 10, weight: .heavy, design: .rounded)) + .foregroundStyle(vm.isConnected ? Color.green : Color.red) + } + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Color.white.opacity(0.06)) + .overlay(Rectangle().strokeBorder(Color(white: 0.28), lineWidth: 1)) + + if let me = vm.myPlayer { + HStack(spacing: 5) { + Text("►").foregroundStyle(SurvivorsTheme.accent) + Text(me.name) + } + .font(.system(size: 11, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Color.white.opacity(0.06)) + .overlay(Rectangle().strokeBorder(Color(white: 0.28), lineWidth: 1)) + } + + Spacer(minLength: 8) + + if let me = vm.myPlayer { + HudHealthMeter(health: me.health) + .frame(width: 160) + } + + HudStatChip(label: "Kills", value: "\(vm.myPlayer?.kills ?? 0)", tint: .orange) + HudStatChip(label: "Swords", value: "\(vm.myPlayer?.weaponCount ?? 0)", tint: SurvivorsTheme.accent) + HudStatChip(label: "Players", value: "\(vm.players.count)", tint: .green) + + if let onMuteToggle = onMuteToggle { + Button { + if isMuted { SoundEffects.shared.play(.muteToggle) } + onMuteToggle() + } label: { + Label(isMuted ? "Muted" : "Audio", systemImage: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") + .labelStyle(.iconOnly) + .frame(width: 28, height: 28) + .background(Color.white.opacity(0.08)) + .overlay(Rectangle().strokeBorder(Color(white: 0.28), lineWidth: 1)) + } + .buttonStyle(.plain) + .help(isMuted ? "Unmute" : "Mute") + } + } + + if !vm.connectionDetail.isEmpty { + Text(vm.connectionDetail) + .font(.system(size: 9, weight: .medium, design: .rounded)) + .foregroundStyle(vm.isConnected ? Color(white: 0.42) : Color.red) + .lineLimit(1) + .padding(.horizontal, 2) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + Rectangle() + .fill(Color(red: 0.07, green: 0.04, blue: 0.16).opacity(0.94)) + .overlay( + Rectangle() + .strokeBorder(Color(red: 0.55, green: 0.82, blue: 1.0).opacity(0.38), lineWidth: 2) + ) + ) + .padding(.top, 0) + .shadow(color: Color(red: 0.3, green: 0.6, blue: 1.0).opacity(0.12), radius: 8, x: 0, y: 4) + } + + private var playingFooter: some View { + HStack(spacing: 8) { + if vm.initialName == nil { + Button { + SoundEffects.shared.play(.buttonPress) + vm.ensureIdentityRegistered(allowFallback: true) + } label: { + Text("Join") + } + .buttonStyle(PixelButtonStyle(filled: true)) + .controlSize(.small) + } + + Button { + SoundEffects.shared.play(.buttonPress) + SpawnTestPlayer.invoke() + } label: { + HStack(spacing: 5) { + Image(systemName: "figure.2.and.child.holdinghands") + Text("Spawn Bot") + } + } + .buttonStyle(PixelButtonStyle()) + .controlSize(.small) + + Text("|") + .font(.system(size: 11, weight: .heavy, design: .rounded)) + .foregroundStyle(Color(white: 0.25)) + + HStack(spacing: 10) { + HStack(spacing: 5) { + Image(systemName: "person.3.fill") + Text(vm.activeLobbyId.map { "LOBBY #\($0)" } ?? "NO LOBBY") + } + HStack(spacing: 5) { + Image(systemName: "dot.radiowaves.left.and.right") + Text("\(vm.players.count) ONLINE") + } + } + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundStyle(Color(white: 0.44)) + + Spacer(minLength: 12) + + Text("WASD / Arrows • Esc: Menu") + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(Color(white: 0.32)) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: 920) + .background( + Rectangle() + .fill(Color(red: 0.07, green: 0.04, blue: 0.16).opacity(0.94)) + .overlay( + Rectangle() + .strokeBorder(Color(red: 0.55, green: 0.82, blue: 1.0).opacity(0.38), lineWidth: 2) + ) + ) + .shadow(color: Color(red: 0.3, green: 0.6, blue: 1.0).opacity(0.10), radius: 8, x: 0, y: -2) + } + + // MARK: - Overlays Removed +} diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameViewModel.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameViewModel.swift new file mode 100644 index 00000000000..1bbed5ff5e1 --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/NinjaGameViewModel.swift @@ -0,0 +1,1510 @@ +import SwiftUI +import Observation +import SpacetimeDB +#if canImport(AppKit) +import AppKit +#endif +import Darwin + +// MARK: - View Model + +@MainActor +@Observable +public class NinjaGameViewModel: SpacetimeClientDelegate { + static let maxPlayersPerLobby = 30 + + public enum NinjaDirection { + case north, south, east, west + } + + public var environment: SpacetimeEnvironment = .local + var players: [Player] = [] + private var allPlayers: [Player] = [] + private var playersById: [UInt64: Player] = [:] + private var playersByLobby: [UInt64: [Player]] = [:] + private var playerCountsByLobby: [UInt64: Int] = [:] + private var playersInActiveLobbySnapshot: [Player] = [] + private var lobbiesSnapshot: [Lobby] = [] + var weapons: [WeaponDrop] = [] + var isConnected = false + var userId: UInt64? + var connectionDetail: String = "" + var initialName: String? + private var client: SpacetimeClient? + var recentEvents: [GameEventEntry] = [] + var renderPlayers: [Player] = [] + + + /// Tracks previous player states to detect drops in health or increases in kills. + private var lastPlayerStates: [UInt64: (health: UInt32, kills: UInt32, weaponCount: UInt32)] = [:] + private var hitFlashUntilByPlayerId: [UInt64: TimeInterval] = [:] + + /// Tracks which way each player is currently facing for animation. + var playerDirections: [UInt64: NinjaDirection] = [:] + /// Tracks if a remote player was moving in the last tick to trigger walk anims. + var playerIsMoving: [UInt64: Bool] = [:] + + // Derived from the active Lobby row for current player + var lobbies: [Lobby] { + lobbiesSnapshot + } + + // Stable lobby identity for UI/screen routing during short replica gaps. + private var stableLobbyId: UInt64? + + var activeLobbyId: UInt64? { + myPlayer?.lobbyId ?? stableLobbyId + } + + var myLobby: Lobby? { + guard let lobbyId = activeLobbyId else { return nil } + return lobbies.first(where: { $0.id == lobbyId }) + } + + func playerCount(forLobbyId lobbyId: UInt64) -> Int { + playerCountsByLobby[lobbyId] ?? 0 + } + + func lobbyIsFull(_ lobby: Lobby) -> Bool { + playerCount(forLobbyId: lobby.id) >= Self.maxPlayersPerLobby + } + + var isPlaying: Bool { + myLobby?.isPlaying ?? false + } + + var myPlayer: Player? { + guard let userId else { return nil } + return playersById[userId] + } + + var playersInMyLobby: [Player] { + guard let lobbyId = activeLobbyId else { return [] } + if myPlayer?.lobbyId == lobbyId { + return playersInActiveLobbySnapshot + } + return playersByLobby[lobbyId] ?? [] + } + + var isDead: Bool { myPlayer?.health == 0 } + + // Local position for client-side prediction (and camera anchor) + var localX: Float = 500 + var localY: Float = 500 + + // Joystick state + var jsActive = false + var jsBase: CGPoint? + var jsVector: CGVector = .zero + + // Keyboard state + private var pressedKeys: Set = [] + private var weaponSpawnLoopTask: Task? + private var isStarted = false + // Don't send movement until the player has joined + var hasJoined = false + var isMenuOpen = false { + didSet { + let sound: SoundEffects.Sound = isMenuOpen ? .menuOpen : .menuClose + SoundEffects.shared.play(sound) + if isMenuOpen { + // Don't keep movement keys "held" when opening an input-driven menu. + pressedKeys.removeAll() + } + } + } + private var previousWeaponCount: UInt32 = 0 + private var previousHealth: UInt32 = 100 + /// Last time each target took damage from any of my swords. + private var lastSwordHitTime: [UInt64: TimeInterval] = [:] + private let swordHitCooldown: TimeInterval = 0.125 + private var lastSwordCollisionSweepTime: TimeInterval = 0 + private let swordCollisionSweepInterval: TimeInterval = 1.0 / 20.0 + private struct SwordOffset: Sendable { + let x: Float + let y: Float + } + private struct CollisionTargetSnapshot: Sendable { + let id: UInt64 + let x: Float + let y: Float + let lastHitTime: TimeInterval + } + private struct SwordCollisionSnapshot: Sendable { + let myX: Float + let myY: Float + let now: TimeInterval + let cooldown: TimeInterval + let swordOffsets: [SwordOffset] + let targets: [CollisionTargetSnapshot] + } + private actor SwordCollisionWorker { + func compute(snapshot: SwordCollisionSnapshot, maxHits: Int) -> [UInt64] { + NinjaGameViewModel.computeSwordCollisionHits(snapshot: snapshot, maxHits: maxHits) + } + } + private let collisionWorker = SwordCollisionWorker() + private var collisionComputeTask: Task? + private var collisionComputeInFlight = false + private let maxSwordAttacksPerSweep = max( + 1, + Int(ProcessInfo.processInfo.environment["NINJA_MAX_ATTACKS_PER_SWEEP"] ?? "") ?? 8 + ) + /// Hit distance: player body radius (~20 pt) + sword-tip radius (~8 pt). + private let swordHitRadius: Float = 28 + private let movementSpeedPerSecond: Float = 180 + private let movementTickInterval: TimeInterval = 1.0 / 60.0 + private let joystickRadius: CGFloat = 50 + private let joystickDeadzone: CGFloat = 5 + private let playerClampPadding: Float = playerEdgePadding + private let networkSendRate: TimeInterval = 1.0 / 20.0 // Send position to server at 20Hz + private var lastNetworkSendTime: TimeInterval = 0 + private var movementLoopTask: Task? + private var lastMovementTick: TimeInterval = Date.timeIntervalSinceReferenceDate + private var localPositionDirty = false // Track if we need to send a network update + private enum PendingLobbyAction { + case create(name: String) + case join(lobbyId: UInt64) + case quickJoin(waitForLobbySnapshot: Bool, attemptsRemaining: Int) + } + private var pendingLobbyAction: PendingLobbyAction? + private var pendingLobbyRetryWorkItem: DispatchWorkItem? + var isQuickJoinActive = false + private var lastReconnectAttemptAt: TimeInterval = 0 + private var pendingQuickJoinFromTitle = false + private let lobbyActionRetryDelay: TimeInterval = 0.35 + private let lobbyActionMaxRetries = 20 + private var missingLobbyIdDetected: UInt64? + private var missingLobbySince: TimeInterval = 0 + private var lastIdentityRepairAttempt: TimeInterval = 0 + private var eventSequence: Int = 0 + private var eventSnapshotLobbyId: UInt64? + private var eventSnapshotPlayersById: [UInt64: Player] = [:] + var smoothedPositions: [UInt64: (x: Float, y: Float)] = [:] + private let verboseNetworkLogging = false + private var lastRenderOrderUpdateTime: TimeInterval = 0 + private let renderOrderUpdateInterval: TimeInterval = 1.0 / 15.0 + private var renderPlayersDirty = false + private var renderSortScratch: [(y: Float, player: Player)] = [] + private var seenPlayerIdsScratch: Set = [] + private var stalePlayerIdsScratch: [UInt64] = [] + + private static var collisionTaskPriority: TaskPriority { + if let override = ProcessInfo.processInfo.environment["NINJA_COLLISION_PRIORITY"]?.lowercased() { + switch override { + case "high": return .high + case "low": return .low + case "background": return .background + default: return .medium + } + } + return AppleSiliconCoreProfile.current.recommendedCollisionPriority + } + private enum HotPathSection: Int, CaseIterable { + case onTransactionTotal + case onTransactionRebuildCaches + case onTransactionEvents + case onTransactionEffects + case tickMovementSwordCollision + + var label: String { + switch self { + case .onTransactionTotal: + return "onTransactionUpdate.total" + case .onTransactionRebuildCaches: + return "onTransactionUpdate.rebuildCaches" + case .onTransactionEvents: + return "onTransactionUpdate.events" + case .onTransactionEffects: + return "onTransactionUpdate.effects" + case .tickMovementSwordCollision: + return "tickMovement.swordCollision" + } + } + + var marksSampleBoundary: Bool { + self == .onTransactionTotal + } + } + + @ObservationIgnored private let perf = HotPathProfiler( + enabled: ProcessInfo.processInfo.environment["NINJA_PROFILE_VM"] == "1", + reportEverySamples: max( + 30, + Int(ProcessInfo.processInfo.environment["NINJA_PROFILE_VM_WINDOW"] ?? "") ?? 180 + ) + ) + + private final class HotPathProfiler { + let enabled: Bool + private let reportEverySamples: Int + private var sectionTotalsNs: [UInt64] + private var sectionCounts: [UInt32] + private var sectionMaxNs: [UInt64] + private var sampleCount: Int = 0 + + init(enabled: Bool, reportEverySamples: Int) { + self.enabled = enabled + self.reportEverySamples = reportEverySamples + let sectionCount = HotPathSection.allCases.count + self.sectionTotalsNs = Array(repeating: 0, count: sectionCount) + self.sectionCounts = Array(repeating: 0, count: sectionCount) + self.sectionMaxNs = Array(repeating: 0, count: sectionCount) + } + + @inline(__always) + func measure(_ section: HotPathSection, _ block: () -> T) -> T { + guard enabled else { return block() } + let start = DispatchTime.now().uptimeNanoseconds + let result = block() + let elapsed = DispatchTime.now().uptimeNanoseconds - start + let index = section.rawValue + sectionTotalsNs[index] &+= elapsed + sectionCounts[index] &+= 1 + if elapsed > sectionMaxNs[index] { + sectionMaxNs[index] = elapsed + } + if section.marksSampleBoundary { + sampleCount += 1 + if sampleCount >= reportEverySamples { + reportAndReset() + } + } + return result + } + + func flushIfNeeded(reason: String) { + guard enabled, sampleCount > 0 else { return } + reportAndReset(reason: reason) + } + + private func reportAndReset() { + reportAndReset(reason: "window") + } + + private func reportAndReset(reason: String) { + guard enabled else { return } + let samples = max(1, sampleCount) + var rows: [String] = [] + rows.reserveCapacity(HotPathSection.allCases.count) + for section in HotPathSection.allCases { + let index = section.rawValue + let count = Int(sectionCounts[index]) + guard count > 0 else { continue } + let totalNs = sectionTotalsNs[index] + let avgMs = (Double(totalNs) / Double(count)) / 1_000_000.0 + let maxMs = Double(sectionMaxNs[index]) / 1_000_000.0 + let perSampleMs = (Double(totalNs) / Double(samples)) / 1_000_000.0 + rows.append( + "\(section.label):avg=\(String(format: "%.3f", avgMs))ms max=\(String(format: "%.3f", maxMs))ms perSample=\(String(format: "%.3f", perSampleMs))ms n=\(count)" + ) + } + let summary = rows.joined(separator: " | ") + print("[NinjaGame][Profile][\(reason)][samples=\(sampleCount)] \(summary)") + sectionTotalsNs = Array(repeating: 0, count: sectionTotalsNs.count) + sectionCounts = Array(repeating: 0, count: sectionCounts.count) + sectionMaxNs = Array(repeating: 0, count: sectionMaxNs.count) + sampleCount = 0 + } + } + + private var isMovementInputActive: Bool { + let dist = sqrt(jsVector.dx * jsVector.dx + jsVector.dy * jsVector.dy) + return dist > joystickDeadzone || !pressedKeys.isEmpty + } + + init(initialName: String? = nil) { + self.initialName = initialName + SpacetimeModule.registerTables() + } + + // MARK: - Connection + + func start() { + guard !isStarted else { return } + isStarted = true + + let newClient = SpacetimeClient( + serverUrl: environment.url, + moduleName: "ninjagame" + ) + newClient.delegate = self + self.client = newClient + SpacetimeClient.shared = newClient + newClient.connect() + + startMovementTimer() + startWeaponSpawner() + } + + func stop() { + guard isStarted else { return } + let shouldSendLeave = hasJoined + perf.flushIfNeeded(reason: "stop") + isConnected = false + movementLoopTask?.cancel() + movementLoopTask = nil + weaponSpawnLoopTask?.cancel() + weaponSpawnLoopTask = nil + collisionComputeTask?.cancel() + collisionComputeTask = nil + collisionComputeInFlight = false + #if canImport(AppKit) + removeKeyboardMonitors() + #endif + isStarted = false + hasJoined = false + clearPendingLobbyAction() + pendingQuickJoinFromTitle = false + missingLobbyIdDetected = nil + missingLobbySince = 0 + lastIdentityRepairAttempt = 0 + stableLobbyId = nil + eventSnapshotLobbyId = nil + eventSnapshotPlayersById.removeAll() + clearGameState() + if let client = self.client { + client.delegate = nil + if shouldSendLeave { + Leave.invoke() + // Give the outbound reducer message one short run-loop window + // to flush before the websocket is closed. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + client.disconnect() + } + } else { + client.disconnect() + } + if SpacetimeClient.shared === client { + SpacetimeClient.shared = nil + } + } + self.client = nil + } + + /// Clears the SpacetimeDB table caches and resets all local game state. + /// Called on both deliberate stop and unexpected disconnect so that stale + /// player/weapon rows from a previous session are never shown on reconnect. + private func clearGameState() { + // Only clear shared caches if this VM owns the active client, + // preventing a stale/background VM from wiping another VM's data. + if SpacetimeClient.shared === client || SpacetimeClient.shared == nil { + PlayerTable.cache.clear() + WeaponDropTable.cache.clear() + LobbyTable.cache.clear() + } + players = [] + allPlayers = [] + playersById.removeAll() + playersByLobby.removeAll() + playerCountsByLobby.removeAll() + playersInActiveLobbySnapshot = [] + lobbiesSnapshot = [] + weapons = [] + userId = nil + isMenuOpen = false + previousWeaponCount = 0 + previousHealth = 100 + lastSwordHitTime.removeAll() + lastSwordCollisionSweepTime = 0 + hitFlashUntilByPlayerId.removeAll() + playerDirections.removeAll() + playerIsMoving.removeAll() + smoothedPositions.removeAll() + pressedKeys.removeAll() + jsActive = false + jsBase = nil + jsVector = .zero + localX = 500 + localY = 500 + localPositionDirty = false + lastNetworkSendTime = 0 + isQuickJoinActive = false + recentEvents = [] + eventSequence = 0 + lastPlayerStates.removeAll() + seenPlayerIdsScratch.removeAll(keepingCapacity: true) + stalePlayerIdsScratch.removeAll(keepingCapacity: true) + renderPlayers = [] + lastRenderOrderUpdateTime = 0 + renderPlayersDirty = false + } + + // MARK: - SpacetimeClientDelegate + + public func onConnect() { + guard isStarted else { return } + isConnected = true + connectionDetail = "" + // Set player name after identifying with the server. + // If initialName is nil (Reconnect flow), don't force rename. + ensureIdentityRegistered(allowFallback: false) + performPendingQuickJoinIfNeeded() + } + + public func onDisconnect(error: Error?) { + guard isStarted else { return } + perf.flushIfNeeded(reason: "disconnect") + isConnected = false + hasJoined = false + clearPendingLobbyAction() + pendingQuickJoinFromTitle = false + missingLobbyIdDetected = nil + missingLobbySince = 0 + stableLobbyId = nil + eventSnapshotLobbyId = nil + eventSnapshotPlayersById.removeAll() + if let error { + connectionDetail = error.localizedDescription + print("[NinjaGame] onDisconnect(error): \(error.localizedDescription)") + } else { + connectionDetail = "" + print("[NinjaGame] onDisconnect(clean)") + } + clearGameState() + } + + public func onIdentityReceived(identity: [UInt8], token: String) { + guard identity.count >= 8 else { return } + self.userId = identity.withUnsafeBytes { $0.loadUnaligned(as: UInt64.self).littleEndian } + print("[NinjaGame] userId set to: \(String(format: "%016llx", self.userId!))") + } + + public func onTransactionUpdate(message: Data?) { + perf.measure(.onTransactionTotal) { + let pTable = PlayerTable.cache.rows + let now = Date.timeIntervalSinceReferenceDate + + perf.measure(.onTransactionRebuildCaches) { + allPlayers = pTable + + playersById.removeAll(keepingCapacity: true) + playersById.reserveCapacity(pTable.count) + playersByLobby.removeAll(keepingCapacity: true) + playerCountsByLobby.removeAll(keepingCapacity: true) + + for p in pTable { + playersById[p.id] = p + if let lobbyId = p.lobbyId { + playersByLobby[lobbyId, default: []].append(p) + playerCountsByLobby[lobbyId, default: 0] += 1 + } + } + + lobbiesSnapshot = LobbyTable.cache.rows.sorted { $0.id < $1.id } + } + + if verboseNetworkLogging { + let idList = pTable.map { String(format: "%016llx", $0.id) }.joined(separator: ", ") + print("[NinjaGame] onTransactionUpdate: userId=\(userId.map { String(format:"%016llx",$0) } ?? "nil") players=[\(idList)] hasJoined=\(hasJoined)") + } + + if let myId = userId, let me = playersById[myId] { + let serverX = clampToWorld(me.x, padding: playerClampPadding) + let serverY = clampToWorld(me.y, padding: playerClampPadding) + if me.lobbyId != nil { + clearPendingLobbyAction() + let transientPrefixes = [ + "Looking for open lobbies", + "No open lobbies found; creating ", + "Joining lobby", + "Creating lobby", + "Registering player as ", + ] + if transientPrefixes.contains(where: { connectionDetail.hasPrefix($0) }) { + connectionDetail = "" + } + } + + if let lobbyId = me.lobbyId { + stableLobbyId = lobbyId + let lobbyExists = lobbiesSnapshot.contains(where: { $0.id == lobbyId }) + if lobbyExists { + missingLobbyIdDetected = nil + missingLobbySince = 0 + } else if missingLobbyIdDetected != lobbyId { + missingLobbyIdDetected = lobbyId + missingLobbySince = now + } else if now - missingLobbySince > 1.2 { + connectionDetail = "Lobby closed; returning to browser…" + LeaveLobby.invoke() + missingLobbyIdDetected = nil + missingLobbySince = 0 + } + } else { + stableLobbyId = nil + missingLobbyIdDetected = nil + missingLobbySince = 0 + } + + if !hasJoined { + // First time we see ourselves — snap camera to server position + hasJoined = true + localX = serverX + localY = serverY + previousWeaponCount = me.weaponCount + previousHealth = me.health + } else { + let respawned = me.health > previousHealth + let driftX = abs(localX - serverX) + let driftY = abs(localY - serverY) + if respawned || driftX > 50 || driftY > 50 { + // Hard snap on respawn or teleport-level drift. + localX = serverX + localY = serverY + } else if !isMovementInputActive && (driftX > 1 || driftY > 1) { + // Only correct small drift when user isn't actively moving, + // which avoids visible rubber-banding while walking. + localX += (serverX - localX) * 0.2 + localY += (serverY - localY) * 0.2 + } + + if me.weaponCount > previousWeaponCount { + SoundEffects.shared.play(.weaponPickup) + } + previousWeaponCount = me.weaponCount + + if me.health == 0 && previousHealth > 0 { + SoundEffects.shared.play(.death) + } + previousHealth = me.health + } + performPendingLobbyActionIfReady() + } else if userId != nil && hasJoined { + // Self row temporarily missing from replica. Try to self-heal once every 2s. + hasJoined = false + stableLobbyId = nil + if now - lastIdentityRepairAttempt > 2.0 { + lastIdentityRepairAttempt = now + connectionDetail = "Player row missing; re-registering…" + ensureIdentityRegistered(allowFallback: true) + } + } + + let lobbyId = activeLobbyId + let scopedPlayers: [Player] + if let lobbyId { + scopedPlayers = playersByLobby[lobbyId] ?? [] + } else { + scopedPlayers = pTable + } + + perf.measure(.onTransactionEvents) { + processLobbyEvents(lobbyPlayers: scopedPlayers, activeLobbyId: lobbyId) + } + + players = scopedPlayers + playersInActiveLobbySnapshot = scopedPlayers + + if let lobbyId { + let allWeapons = WeaponDropTable.cache.rows + weapons.removeAll(keepingCapacity: true) + weapons.reserveCapacity(allWeapons.count) + for weapon in allWeapons where weapon.lobbyId == lobbyId { + weapons.append(weapon) + } + } else { + weapons = WeaponDropTable.cache.rows + } + + renderPlayersDirty = true + refreshRenderPlayersIfNeeded(now: now) + + perf.measure(.onTransactionEffects) { + seenPlayerIdsScratch.removeAll(keepingCapacity: true) + seenPlayerIdsScratch.reserveCapacity(pTable.count) + + for p in pTable { + seenPlayerIdsScratch.insert(p.id) + guard let last = lastPlayerStates[p.id] else { + lastPlayerStates[p.id] = (p.health, p.kills, p.weaponCount) + continue + } + + if p.health < last.health && p.health > 0 { + EffectManager.shared.spawnHit(x: p.x, y: p.y, value: "-\(last.health - p.health)") + hitFlashUntilByPlayerId[p.id] = now + 0.15 + } + + if p.health == 0 && last.health > 0 { + EffectManager.shared.spawnDeath(x: p.x, y: p.y) + } + + if p.kills > last.kills { + EffectManager.shared.spawnKill(x: p.x, y: p.y) + } + + if p.weaponCount > last.weaponCount { + EffectManager.shared.spawnPickup(x: p.x, y: p.y, value: "+1 SWORD") + } + + lastPlayerStates[p.id] = (p.health, p.kills, p.weaponCount) + } + + if lastPlayerStates.count > seenPlayerIdsScratch.count { + stalePlayerIdsScratch.removeAll(keepingCapacity: true) + stalePlayerIdsScratch.reserveCapacity(lastPlayerStates.count - seenPlayerIdsScratch.count) + for id in lastPlayerStates.keys where !seenPlayerIdsScratch.contains(id) { + stalePlayerIdsScratch.append(id) + } + for id in stalePlayerIdsScratch { + lastPlayerStates.removeValue(forKey: id) + hitFlashUntilByPlayerId.removeValue(forKey: id) + } + } + } + } + } + + func playerIsHitFlashing(_ playerId: UInt64, at now: TimeInterval) -> Bool { + (hitFlashUntilByPlayerId[playerId] ?? 0) > now + } + + var isCollisionComputeInFlight: Bool { + collisionComputeInFlight + } + + public func onReducerError(reducer: String, message: String, isInternal: Bool) { + let lowered = message.lowercased() + if lowered.contains("no such reducer") { + connectionDetail = "missing reducer '\(reducer)' on server; publish ninjagame module" + } else { + connectionDetail = "\(isInternal ? "internal" : "reducer") error (\(reducer))" + } + print("[NinjaGame] reducer error for '\(reducer)': \(message)") + } + + func ensureIdentityRegistered(allowFallback: Bool) { + let trimmedInitial = initialName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedInitial.isEmpty { + SetName.invoke(name: trimmedInitial) + return + } + + guard allowFallback else { return } + + if let currentName = myPlayer?.name, + !currentName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return + } + + let fallbackName: String + if let userId { + fallbackName = "Player \(String(format: "%04X", userId & 0xFFFF))" + } else { + fallbackName = "Player \(Int.random(in: 1...9999))" + } + initialName = fallbackName + connectionDetail = "Registering player as \(fallbackName)…" + SetName.invoke(name: fallbackName) + } + + func renameCurrentPlayer(to name: String) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + initialName = trimmed + connectionDetail = "Updating name to \(trimmed)…" + SetName.invoke(name: trimmed) + } + + func scheduleQuickJoinFromTitle() { + pendingQuickJoinFromTitle = true + performPendingQuickJoinIfNeeded() + } + + func clearPendingQuickJoinFromTitle() { + pendingQuickJoinFromTitle = false + } + + private func performPendingQuickJoinIfNeeded() { + guard pendingQuickJoinFromTitle, isConnected else { return } + pendingQuickJoinFromTitle = false + quickJoinFirstLobbyWithRetry(waitForLobbySnapshot: true, attemptsRemaining: 6) + } + + func refreshLobbies() { + guard let client = client else { return } + guard isStarted else { return } + SoundEffects.shared.play(.buttonPress) + connectionDetail = "Refreshing connection..." + client.disconnect() + // Small delay to allow disconnect to settle before reconnecting + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + guard let self else { return } + guard self.isStarted, self.client === client else { return } + client.connect() + } + } + + private func clearPendingLobbyAction() { + pendingLobbyAction = nil + pendingLobbyRetryWorkItem?.cancel() + pendingLobbyRetryWorkItem = nil + } + + private func queuePendingLobbyAction(_ action: PendingLobbyAction, detail: String) { + pendingLobbyAction = action + connectionDetail = detail + ensureIdentityRegistered(allowFallback: true) + schedulePendingLobbyActionRetry(attemptsRemaining: lobbyActionMaxRetries) + } + + private func schedulePendingLobbyActionRetry(attemptsRemaining: Int) { + pendingLobbyRetryWorkItem?.cancel() + + guard pendingLobbyAction != nil else { return } + guard isStarted else { + clearPendingLobbyAction() + return + } + guard attemptsRemaining > 0 else { + recoverConnectionForPendingLobbyAction() + return + } + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + guard self.pendingLobbyAction != nil else { return } + guard self.isStarted else { + self.clearPendingLobbyAction() + return + } + guard self.isConnected else { + self.requestReconnectIfNeeded(detail: "Disconnected; reconnecting…") + self.schedulePendingLobbyActionRetry(attemptsRemaining: attemptsRemaining - 1) + return + } + guard self.myPlayer != nil, self.hasJoined else { + self.ensureIdentityRegistered(allowFallback: true) + self.schedulePendingLobbyActionRetry(attemptsRemaining: attemptsRemaining - 1) + return + } + self.performPendingLobbyActionIfReady() + } + + pendingLobbyRetryWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + lobbyActionRetryDelay, execute: workItem) + } + + private func recoverConnectionForPendingLobbyAction() { + guard pendingLobbyAction != nil else { return } + guard isStarted else { + clearPendingLobbyAction() + return + } + guard let client else { return } + + connectionDetail = "Player row still missing; reconnecting…" + pendingLobbyRetryWorkItem?.cancel() + pendingLobbyRetryWorkItem = nil + client.disconnect() + client.delegate = self + client.connect() + schedulePendingLobbyActionRetry(attemptsRemaining: lobbyActionMaxRetries) + } + + private func requestReconnectIfNeeded(detail: String) { + guard isStarted else { return } + connectionDetail = detail + let now = Date.timeIntervalSinceReferenceDate + if now - lastReconnectAttemptAt < 1.2 { + return + } + lastReconnectAttemptAt = now + print("[NinjaGame] reconnect requested: \(detail)") + client?.connect() + } + + private func performPendingLobbyActionIfReady() { + guard isStarted else { + clearPendingLobbyAction() + return + } + guard let pending = pendingLobbyAction else { return } + guard isConnected, hasJoined, let me = myPlayer else { return } + guard me.lobbyId == nil else { + clearPendingLobbyAction() + return + } + + clearPendingLobbyAction() + switch pending { + case .create(let name): + createLobbyWithRetry(name: name, attemptsRemaining: lobbyActionMaxRetries) + case .join(let lobbyId): + joinLobbyWithRetry(lobbyId: lobbyId, attemptsRemaining: lobbyActionMaxRetries) + case .quickJoin(let waitForLobbySnapshot, let attemptsRemaining): + quickJoinFirstLobbyWithRetry( + waitForLobbySnapshot: waitForLobbySnapshot, + attemptsRemaining: attemptsRemaining + ) + } + } + + func createLobbyWithRetry(name: String, attemptsRemaining: Int? = nil) { + guard isStarted else { return } + guard isConnected else { + requestReconnectIfNeeded(detail: "Disconnected; reconnecting…") + return + } + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let attempts = attemptsRemaining ?? lobbyActionMaxRetries + guard myPlayer?.lobbyId == nil else { return } + + if myPlayer == nil || !hasJoined { + queuePendingLobbyAction(.create(name: trimmed), detail: "Creating lobby… waiting for player row.") + return + } + + clearPendingLobbyAction() + connectionDetail = "Creating lobby…" + CreateLobby.invoke(name: trimmed) + + guard attempts > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + lobbyActionRetryDelay) { [weak self] in + guard let self else { return } + guard self.isStarted else { return } + guard self.isConnected, self.myPlayer?.lobbyId == nil else { return } + self.createLobbyWithRetry(name: trimmed, attemptsRemaining: attempts - 1) + } + } + + func joinLobbyWithRetry(lobbyId: UInt64, attemptsRemaining: Int? = nil) { + guard isStarted else { return } + guard isConnected else { + requestReconnectIfNeeded(detail: "Disconnected; reconnecting…") + return + } + let attempts = attemptsRemaining ?? lobbyActionMaxRetries + guard myPlayer?.lobbyId == nil else { return } + + if myPlayer == nil || !hasJoined { + queuePendingLobbyAction(.join(lobbyId: lobbyId), detail: "Joining lobby… waiting for player row.") + return + } + clearPendingLobbyAction() + connectionDetail = "Joining lobby…" + print("[NinjaGame] joinLobbyWithRetry invoke lobbyId=\(lobbyId), attempts=\(attempts)") + JoinLobby.invoke(lobbyId: lobbyId) + + guard attempts > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + lobbyActionRetryDelay) { [weak self] in + guard let self else { return } + guard self.isStarted else { return } + guard self.isConnected, self.myPlayer?.lobbyId == nil else { return } + self.joinLobbyWithRetry(lobbyId: lobbyId, attemptsRemaining: attempts - 1) + } + } + + func quickJoinFirstLobbyWithRetry(waitForLobbySnapshot: Bool = false, attemptsRemaining: Int = 0) { + guard isStarted else { + isQuickJoinActive = false + return + } + isQuickJoinActive = true + guard isConnected else { + requestReconnectIfNeeded(detail: "Disconnected; reconnecting…") + return + } + guard myPlayer?.lobbyId == nil else { + clearPendingLobbyAction() + return + } + + if myPlayer == nil || !hasJoined { + queuePendingLobbyAction( + .quickJoin(waitForLobbySnapshot: waitForLobbySnapshot, attemptsRemaining: attemptsRemaining), + detail: "Joining lobby… waiting for player registration." + ) + return + } + + let candidateLobby = + lobbies.first(where: { !$0.isPlaying && !lobbyIsFull($0) }) ?? + lobbies.first(where: { !lobbyIsFull($0) }) + + guard let targetLobby = candidateLobby else { + if waitForLobbySnapshot && attemptsRemaining > 0 { + connectionDetail = "Looking for open lobbies…" + DispatchQueue.main.asyncAfter(deadline: .now() + 0.30) { [weak self] in + guard let self else { return } + guard self.isStarted else { return } + self.quickJoinFirstLobbyWithRetry( + waitForLobbySnapshot: true, + attemptsRemaining: attemptsRemaining - 1 + ) + } + } else { + let baseName = (myPlayer?.name ?? initialName ?? "Player") + .trimmingCharacters(in: .whitespacesAndNewlines) + let lobbyName = baseName.isEmpty ? "Quick Lobby" : "\(baseName)'s Lobby" + connectionDetail = "No open lobbies found; creating \(lobbyName)…" + createLobbyWithRetry(name: lobbyName, attemptsRemaining: lobbyActionMaxRetries) + } + return + } + print("[NinjaGame] quickJoin target lobbyId=\(targetLobby.id) isPlaying=\(targetLobby.isPlaying)") + joinLobbyWithRetry(lobbyId: targetLobby.id, attemptsRemaining: lobbyActionMaxRetries) + } + + private func appendEvent(_ text: String, kind: GameEventEntry.Kind) { + eventSequence += 1 + recentEvents.append(GameEventEntry(id: eventSequence, text: text, kind: kind, timestamp: Date())) + if recentEvents.count > 30 { + recentEvents.removeFirst(recentEvents.count - 30) + } + } + + private func processLobbyEvents(lobbyPlayers: [Player], activeLobbyId: UInt64?) { + guard let lobbyId = activeLobbyId else { + eventSnapshotLobbyId = nil + eventSnapshotPlayersById.removeAll() + return + } + + var currentById: [UInt64: Player] = [:] + currentById.reserveCapacity(lobbyPlayers.count) + for player in lobbyPlayers { + currentById[player.id] = player + } + + // Prime snapshot when entering/changing lobbies to avoid noisy initial flood. + guard eventSnapshotLobbyId == lobbyId else { + eventSnapshotLobbyId = lobbyId + eventSnapshotPlayersById = currentById + return + } + + let previousById = eventSnapshotPlayersById + + for player in lobbyPlayers where previousById[player.id] == nil { + appendEvent("\(player.name) joined the lobby", kind: .info) + } + + for (id, player) in previousById where currentById[id] == nil { + appendEvent("\(player.name) left the lobby", kind: .info) + } + + for (id, current) in currentById { + guard let previous = previousById[id] else { continue } + if current.kills > previous.kills { + let delta = Int(current.kills - previous.kills) + for _ in 0.. Float { + let minValue = worldMin + padding + let maxValue = worldMax - padding + return max(minValue, min(maxValue, value)) + } + + func randomWeaponSpawn() -> (x: Float, y: Float) { + let minSpawn = worldMin + weaponSpawnPadding + let maxSpawn = worldMax - weaponSpawnPadding + return ( + Float.random(in: minSpawn...maxSpawn), + Float.random(in: minSpawn...maxSpawn) + ) + } + + private func moveBy(dx: Float, dy: Float) { + guard hasJoined else { return } + localX = clampToWorld(localX + dx, padding: playerClampPadding) + localY = clampToWorld(localY + dy, padding: playerClampPadding) + localPositionDirty = true + } + + /// Send position to server at a throttled rate (20Hz) + private func flushPositionIfNeeded(now: TimeInterval) { + guard localPositionDirty else { return } + guard now - lastNetworkSendTime >= networkSendRate else { return } + lastNetworkSendTime = now + localPositionDirty = false + MovePlayer.invoke(x: localX, y: localY) + } + + func updateJoystick(active: Bool, base: CGPoint = .zero, current: CGPoint = .zero) { + jsActive = active + if active { + jsBase = base + let dx = current.x - base.x + let dy = current.y - base.y + let dist = sqrt(dx * dx + dy * dy) + + if dist > joystickRadius { + jsVector = CGVector(dx: dx / dist * joystickRadius, dy: dy / dist * joystickRadius) + } else { + jsVector = CGVector(dx: dx, dy: dy) + } + } else { + jsBase = nil + jsVector = .zero + } + } + + private func startMovementTimer() { + movementLoopTask?.cancel() + lastMovementTick = Date.timeIntervalSinceReferenceDate + let tickNanos = UInt64(max(1.0, movementTickInterval * 1_000_000_000.0)) + movementLoopTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + self.tickMovement(now: Date.timeIntervalSinceReferenceDate) + do { + try await Task.sleep(nanoseconds: tickNanos) + } catch { + break + } + } + } + } + + private func tickMovement(now: TimeInterval = Date.timeIntervalSinceReferenceDate) { + guard hasJoined && !isMenuOpen else { return } + let rawDt = now - lastMovementTick + lastMovementTick = now + let dt = Float(max(0, min(rawDt, 0.05))) + + // Smooth remote players: use exponential smoothing (EMA) for state-of-the-art lean netcode. + // factor = 1 - pow(damping, dt * tickRate). 0.15 @ 60Hz is roughly 9 per second. + let remoteSmoothingFactor = 1.0 - pow(0.001, dt) + var hasRemoteSmoothingUpdate = false + let movingThresholdSq: Float = 0.05 * 0.05 + for p in players where p.id != userId { + let currentTuple = smoothedPositions[p.id] ?? (p.x, p.y) + let current = SIMD2(currentTuple.x, currentTuple.y) + let target = SIMD2(p.x, p.y) + let next = current + (target - current) * remoteSmoothingFactor + smoothedPositions[p.id] = (next.x, next.y) + + let delta = next - current + let distSq = delta.x * delta.x + delta.y * delta.y + if distSq > movingThresholdSq { + playerIsMoving[p.id] = true + if abs(delta.x) > abs(delta.y) { + playerDirections[p.id] = delta.x > 0 ? .east : .west + } else { + playerDirections[p.id] = delta.y > 0 ? .south : .north + } + hasRemoteSmoothingUpdate = true + } else { + playerIsMoving[p.id] = false + } + } + if hasRemoteSmoothingUpdate { + renderPlayersDirty = true + } + refreshRenderPlayersIfNeeded(now: now) + + // Process published effect events (hand off to managers) + // This will be handled by the UI listening to the VM's effectEvents array + + guard dt > 0 else { + flushPositionIfNeeded(now: now) + return + } + + var input = SIMD2(repeating: 0) + + if jsActive && jsVector != .zero { + let dist = sqrt(jsVector.dx * jsVector.dx + jsVector.dy * jsVector.dy) + if dist > joystickDeadzone { + input.x = Float(jsVector.dx / joystickRadius) + input.y = Float(jsVector.dy / joystickRadius) + } + } else { + // Keyboard: W=13, A=0, S=1, D=2, ←=123, →=124, ↓=125, ↑=126 + if pressedKeys.contains(13) || pressedKeys.contains(126) { input.y -= 1 } + if pressedKeys.contains(1) || pressedKeys.contains(125) { input.y += 1 } + if pressedKeys.contains(0) || pressedKeys.contains(123) { input.x -= 1 } + if pressedKeys.contains(2) || pressedKeys.contains(124) { input.x += 1 } + } + + if input.x != 0 || input.y != 0 { + let lenSq = input.x * input.x + input.y * input.y + let invLen = 1.0 / max(1.0, sqrt(lenSq)) + let direction = input * invLen + let step = movementSpeedPerSecond * dt + moveBy(dx: direction.x * step, dy: direction.y * step) + renderPlayersDirty = true + + // Set my facing direction + if let myId = userId { + playerIsMoving[myId] = true + if abs(direction.x) > abs(direction.y) { + playerDirections[myId] = direction.x > 0 ? .east : .west + } else { + playerDirections[myId] = direction.y > 0 ? .south : .north + } + } + } else if let myId = userId { + playerIsMoving[myId] = false + } + + // Check for sword-to-player collisions. + perf.measure(.tickMovementSwordCollision) { + checkSwordCollisions(now: now) + } + + // Throttled network send so we don't flood the server + flushPositionIfNeeded(now: now) + } + + /// Checks whether any of my orbiting swords are touching another player. + /// + /// Uses the **same** `swordPositions(count:t:)` call as the renderer, so + /// the hitboxes are pixel-perfect matches of what's drawn on screen. + /// + /// Coordinate space: world units == view points (the renderer draws world + /// coords directly with no scale transform), so CGFloat sword offsets can + /// be cast to Float and added to the Float world position without conversion. + private func checkSwordCollisions(now: TimeInterval) { + guard hasJoined, !isMenuOpen else { return } + guard let myId = userId else { return } + guard let me = myPlayer, me.weaponCount > 0, me.health > 0 else { return } + guard players.count > 1 else { return } + guard now - lastSwordCollisionSweepTime >= swordCollisionSweepInterval else { return } + guard !collisionComputeInFlight else { return } + lastSwordCollisionSweepTime = now + + let myX = localX + let myY = localY + + var swordOffsets: [SwordOffset] = [] + swordOffsets.reserveCapacity(Int(me.weaponCount)) + forEachSwordPosition(count: Int(me.weaponCount), t: now) { offset in + swordOffsets.append(SwordOffset(x: Float(offset.x), y: Float(offset.y))) + } + guard !swordOffsets.isEmpty else { return } + + var targets: [CollisionTargetSnapshot] = [] + targets.reserveCapacity(players.count - 1) + for target in players where target.id != myId && target.health > 0 { + targets.append( + CollisionTargetSnapshot( + id: target.id, + x: target.x, + y: target.y, + lastHitTime: lastSwordHitTime[target.id] ?? -Double.infinity + ) + ) + } + guard !targets.isEmpty else { return } + + collisionComputeInFlight = true + let snapshot = SwordCollisionSnapshot( + myX: myX, + myY: myY, + now: now, + cooldown: swordHitCooldown, + swordOffsets: swordOffsets, + targets: targets + ) + let maxAttacksPerSweep = maxSwordAttacksPerSweep + collisionComputeTask = Task(priority: Self.collisionTaskPriority) { [weak self] in + guard let self else { return } + let hits = await self.collisionWorker.compute(snapshot: snapshot, maxHits: maxAttacksPerSweep) + guard !Task.isCancelled else { return } + await MainActor.run { [weak self] in + guard let self else { return } + self.collisionComputeTask = nil + self.collisionComputeInFlight = false + guard self.hasJoined, !self.isMenuOpen else { return } + guard !hits.isEmpty else { return } + + var didHit = false + for targetId in hits { + // Re-validate cooldown at apply time to avoid duplicate sends + // when snapshots overlap with newer state. + let lastHit = self.lastSwordHitTime[targetId] ?? -Double.infinity + guard snapshot.now - lastHit >= self.swordHitCooldown else { continue } + self.lastSwordHitTime[targetId] = snapshot.now + Attack.invoke(targetId: targetId) + didHit = true + } + if didHit { + SoundEffects.shared.play(.attack) + } + } + } + } + + nonisolated private static func computeSwordCollisionHits( + snapshot: SwordCollisionSnapshot, + maxHits: Int + ) -> [UInt64] { + guard !snapshot.swordOffsets.isEmpty, !snapshot.targets.isEmpty else { return [] } + guard maxHits > 0 else { return [] } + + struct SwordBounds { + let left: Float + let right: Float + let top: Float + let bottom: Float + } + + // Pixel-perfect AABB (Axis-Aligned Bounding Box) dimensions (width / 2, height / 2) + // Ninja is 36x42, Sword is 15x39 + let ninjaHalfW: Float = 18.0 + let ninjaHalfH: Float = 21.0 + let swordHalfW: Float = 7.5 + let swordHalfH: Float = 19.5 + let myPosition = SIMD2(snapshot.myX, snapshot.myY) + + var swordBounds: [SwordBounds] = [] + swordBounds.reserveCapacity(snapshot.swordOffsets.count) + + var maxSwordRadiusSq: Float = 0 + var swordsMinLeft = Float.greatestFiniteMagnitude + var swordsMaxRight = -Float.greatestFiniteMagnitude + var swordsMinTop = Float.greatestFiniteMagnitude + var swordsMaxBottom = -Float.greatestFiniteMagnitude + + for offset in snapshot.swordOffsets { + let offsetVec = SIMD2(offset.x, offset.y) + let swordPos = myPosition + offsetVec + let bounds = SwordBounds( + left: swordPos.x - swordHalfW, + right: swordPos.x + swordHalfW, + top: swordPos.y - swordHalfH, + bottom: swordPos.y + swordHalfH + ) + swordBounds.append(bounds) + + if bounds.left < swordsMinLeft { swordsMinLeft = bounds.left } + if bounds.right > swordsMaxRight { swordsMaxRight = bounds.right } + if bounds.top < swordsMinTop { swordsMinTop = bounds.top } + if bounds.bottom > swordsMaxBottom { swordsMaxBottom = bounds.bottom } + + let radiusSq = offsetVec.x * offsetVec.x + offsetVec.y * offsetVec.y + if radiusSq > maxSwordRadiusSq { maxSwordRadiusSq = radiusSq } + } + + let maxSwordRadius = sqrt(maxSwordRadiusSq) + let targetCullRadius = maxSwordRadius + ninjaHalfW + swordHalfH + let targetCullRadiusSq = targetCullRadius * targetCullRadius + + // Coarse union bounds for all swords, expanded by target body size. + let expandedLeft = swordsMinLeft - ninjaHalfW + let expandedRight = swordsMaxRight + ninjaHalfW + let expandedTop = swordsMinTop - ninjaHalfH + let expandedBottom = swordsMaxBottom + ninjaHalfH + + var hitTargets: [UInt64] = [] + hitTargets.reserveCapacity(min(snapshot.targets.count, maxHits)) + + for target in snapshot.targets { + guard snapshot.now - target.lastHitTime >= snapshot.cooldown else { continue } + + let targetCenterDelta = SIMD2(target.x, target.y) - myPosition + let centerDistSq = targetCenterDelta.x * targetCenterDelta.x + targetCenterDelta.y * targetCenterDelta.y + guard centerDistSq <= targetCullRadiusSq else { continue } + + // Target bounds + let tLeft = target.x - ninjaHalfW + let tRight = target.x + ninjaHalfW + let tTop = target.y - ninjaHalfH + let tBottom = target.y + ninjaHalfH + + guard tRight >= expandedLeft, tLeft <= expandedRight, tBottom >= expandedTop, tTop <= expandedBottom else { + continue + } + + for sword in swordBounds { + if sword.left <= tRight && + sword.right >= tLeft && + sword.top <= tBottom && + sword.bottom >= tTop { + hitTargets.append(target.id) + break + } + } + + if hitTargets.count >= maxHits { + break + } + } + + return hitTargets + } + + private func renderY(for player: Player) -> Float { + if player.id == userId && hasJoined { + return localY + } + return smoothedPositions[player.id]?.y ?? player.y + } + + private func refreshRenderPlayersIfNeeded(now: TimeInterval, force: Bool = false) { + if !force && !renderPlayersDirty { + return + } + if !force && now - lastRenderOrderUpdateTime < renderOrderUpdateInterval { + return + } + lastRenderOrderUpdateTime = now + renderPlayersDirty = false + renderSortScratch.removeAll(keepingCapacity: true) + renderSortScratch.reserveCapacity(players.count) + for player in players where player.health > 0 { + renderSortScratch.append((y: renderY(for: player), player: player)) + } + renderSortScratch.sort { $0.y < $1.y } + renderPlayers.removeAll(keepingCapacity: true) + renderPlayers.reserveCapacity(renderSortScratch.count) + for entry in renderSortScratch { + renderPlayers.append(entry.player) + } + } + + private func startWeaponSpawner() { + weaponSpawnLoopTask?.cancel() + let tickNanos = UInt64(max(1.0, weaponSpawnInterval * 1_000_000_000.0)) + weaponSpawnLoopTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + do { + try await Task.sleep(nanoseconds: tickNanos) + } catch { + break + } + guard self.hasJoined, self.isConnected else { continue } + + // Limit weapons on the ground to avoid clutter. + if self.weapons.count < maxGroundWeapons { + let spawn = self.randomWeaponSpawn() + SpawnWeapon.invoke(x: spawn.x, y: spawn.y) + } + } + } + } + + // MARK: - Keyboard (macOS) + + #if canImport(AppKit) + private var keyDownMonitor: Any? + private var keyUpMonitor: Any? + + private static let movementKeyCodes: Set = [0, 1, 2, 13, 123, 124, 125, 126] + private static let menuKeyCodes: Set = [12, 53] // Q=12, Esc=53 + private static let spawnBotKeyCode: UInt16 = 14 // E + + private func shouldConsumeMenuHotkey(_ keyCode: UInt16) -> Bool { + guard keyCode == 12 else { + // Esc should always be available for menu toggle. + return true + } + + // Don't treat Q as a menu hotkey while typing into text inputs. + if let responder = NSApp.keyWindow?.firstResponder, responder is NSTextView { + return false + } + return true + } + + func installKeyboardMonitor() { + guard keyDownMonitor == nil else { return } + + keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self else { return event } + + if Self.menuKeyCodes.contains(event.keyCode) { + guard self.shouldConsumeMenuHotkey(event.keyCode) else { return event } + Task { @MainActor in self.isMenuOpen.toggle() } + return nil + } + + // While pause/menu UI is open, don't consume gameplay movement keys; + // let focused controls (e.g. rename TextField) receive raw keyboard input. + if self.isMenuOpen { + return event + } + + if event.keyCode == Self.spawnBotKeyCode { + Task { @MainActor in + guard self.hasJoined, self.isConnected else { return } + SoundEffects.shared.play(.buttonPress) + SpawnTestPlayer.invoke() + } + return nil + } + + guard Self.movementKeyCodes.contains(event.keyCode) else { return event } + Task { @MainActor in self.pressedKeys.insert(event.keyCode) } + return nil + } + keyUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyUp) { [weak self] event in + guard let self else { return event } + + if Self.menuKeyCodes.contains(event.keyCode) { + guard self.shouldConsumeMenuHotkey(event.keyCode) else { return event } + return nil + } + + if self.isMenuOpen { + return event + } + + if event.keyCode == Self.spawnBotKeyCode { + return nil + } + + guard Self.movementKeyCodes.contains(event.keyCode) else { return event } + Task { @MainActor in self.pressedKeys.remove(event.keyCode) } + return nil + } + } + + private func removeKeyboardMonitors() { + if let m = keyDownMonitor { NSEvent.removeMonitor(m); keyDownMonitor = nil } + if let m = keyUpMonitor { NSEvent.removeMonitor(m); keyUpMonitor = nil } + } + #endif + + func uninstallKeyboardMonitor() { + #if canImport(AppKit) + removeKeyboardMonitors() + #endif + } +} + private struct AppleSiliconCoreProfile { + let performanceCores: Int + let efficiencyCores: Int + let activeCores: Int + + static let current = detect() + + var recommendedCollisionPriority: TaskPriority { + performanceCores >= 4 ? .high : .medium + } + + private static func detect() -> AppleSiliconCoreProfile { + let perf = sysctlInt("hw.perflevel0.physicalcpu") + let eff = sysctlInt("hw.perflevel1.physicalcpu") + let active = ProcessInfo.processInfo.activeProcessorCount + return AppleSiliconCoreProfile( + performanceCores: max(0, perf), + efficiencyCores: max(0, eff), + activeCores: max(1, active) + ) + } + + private static func sysctlInt(_ name: String) -> Int { + var value: Int32 = 0 + var size = MemoryLayout.size + let result = name.withCString { + sysctlbyname($0, &value, &size, nil, 0) + } + guard result == 0 else { return 0 } + return Int(value) + } + } diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/Assets.xcassets/Contents.json b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000000..65af1f2159f --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/Assets.xcassets/spacetime_logo.imageset/Contents.json b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/Assets.xcassets/spacetime_logo.imageset/Contents.json new file mode 100644 index 00000000000..9ef648c5e8a --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/Assets.xcassets/spacetime_logo.imageset/Contents.json @@ -0,0 +1,27 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "logo-light.svg", + "scale": "1x" + }, + { + "idiom": "universal", + "filename": "logo-dark.svg", + "scale": "1x", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "version": 1, + "author": "xcode" + }, + "properties": { + "preserves-vector-representation": true + } +} \ No newline at end of file diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/Assets.xcassets/spacetime_logo.imageset/logo-dark.svg b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/Assets.xcassets/spacetime_logo.imageset/logo-dark.svg new file mode 100644 index 00000000000..0c6aa67f60e --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/Assets.xcassets/spacetime_logo.imageset/logo-dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/Assets.xcassets/spacetime_logo.imageset/logo-light.svg b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/Assets.xcassets/spacetime_logo.imageset/logo-light.svg new file mode 100644 index 00000000000..bbcf23a13ae --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/Assets.xcassets/spacetime_logo.imageset/logo-light.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/SpaceTimeDB Survivors - Alternate Music.m4a b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/SpaceTimeDB Survivors - Alternate Music.m4a new file mode 100644 index 00000000000..d249f4abdbc Binary files /dev/null and b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/SpaceTimeDB Survivors - Alternate Music.m4a differ diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/SpaceTimeDB Survivors.m4a b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/SpaceTimeDB Survivors.m4a new file mode 100644 index 00000000000..28d881b8899 Binary files /dev/null and b/demo/ninja-game/client-swift/Sources/NinjaGameClient/Resources/SpaceTimeDB Survivors.m4a differ diff --git a/demo/ninja-game/client-swift/Sources/NinjaGameClient/SDKCompat.swift b/demo/ninja-game/client-swift/Sources/NinjaGameClient/SDKCompat.swift new file mode 100644 index 00000000000..a9db768eeff --- /dev/null +++ b/demo/ninja-game/client-swift/Sources/NinjaGameClient/SDKCompat.swift @@ -0,0 +1,5 @@ +import SpacetimeDB + +public typealias BSATNEncoder = SpacetimeDB.BSATNEncoder +public typealias SpacetimeClient = SpacetimeDB.SpacetimeClient +public typealias TableCache = SpacetimeDB.TableCache diff --git a/demo/ninja-game/client-swift/test_crop.py b/demo/ninja-game/client-swift/test_crop.py new file mode 100644 index 00000000000..e0d2b201242 --- /dev/null +++ b/demo/ninja-game/client-swift/test_crop.py @@ -0,0 +1,15 @@ +from PIL import Image +im = Image.open('/Users/avi/Github/SpacetimeDB/ninja_atlas.png').convert('RGBA') +w, h = im.size +cell_w = 256 +cell_h = 256 + +def show_cell(cx, cy): + c = im.crop((cx*cell_w, cy*cell_h, (cx+1)*cell_w, (cy+1)*cell_h)) + return c.getextrema()[3][1] > 0 + +for y in range(6): + s = [] + for x in range(11): + s.append('X' if show_cell(x, y) else '.') + print(''.join(s)) diff --git a/demo/ninja-game/client-swift/test_layout.py b/demo/ninja-game/client-swift/test_layout.py new file mode 100644 index 00000000000..e364ad15b6b --- /dev/null +++ b/demo/ninja-game/client-swift/test_layout.py @@ -0,0 +1,21 @@ +from PIL import Image +import sys +im = Image.open('/Users/avi/Github/SpacetimeDB/ninja_atlas.png').convert('RGBA') +w, h = im.size + +grid_x = 22 +grid_y = 12 +cell_w = w // grid_x +cell_h = h // grid_y +print(f"Cell size: {cell_w}x{cell_h}") + +for ry in range(grid_y): + row_chars = [] + for rx in range(grid_x): + cell = im.crop((rx*cell_w, ry*cell_h, (rx+1)*cell_w, (ry+1)*cell_h)) + extrema = cell.getextrema() + if extrema[3][1] > 0: # not fully transparent + row_chars.append("X") + else: + row_chars.append(".") + print("".join(row_chars)) diff --git a/demo/ninja-game/spacetime.json b/demo/ninja-game/spacetime.json new file mode 100644 index 00000000000..14681d96ab8 --- /dev/null +++ b/demo/ninja-game/spacetime.json @@ -0,0 +1,4 @@ +{ + "module-path": "./spacetimedb", + "server": "maincloud" +} \ No newline at end of file diff --git a/demo/ninja-game/spacetime.local.json b/demo/ninja-game/spacetime.local.json new file mode 100644 index 00000000000..716224c96c0 --- /dev/null +++ b/demo/ninja-game/spacetime.local.json @@ -0,0 +1,3 @@ +{ + "database": "ninjagame" +} \ No newline at end of file diff --git a/demo/ninja-game/spacetimedb/Cargo.lock b/demo/ninja-game/spacetimedb/Cargo.lock new file mode 100644 index 00000000000..83f32af501b --- /dev/null +++ b/demo/ninja-game/spacetimedb/Cargo.lock @@ -0,0 +1,960 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approx" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0e60b75072ecd4168020818c0107f2857bb6c4e64252d8d3983f6263b40a5c3" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "decorum" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "281759d3c8a14f5c3f0c49363be56810fcd7f910422f97f2db850c2920fde5cf" +dependencies = [ + "approx", + "num-traits", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "ethnum" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +dependencies = [ + "serde", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lean_string" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962df00ba70ac8d5ca5c064e17e5c3d090c087fd8d21aa45096c716b169da514" +dependencies = [ + "castaway", + "itoa", + "ryu", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "ninja-game" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "second-stack" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4904c83c6e51f1b9b08bfa5a86f35a51798e8307186e6f5513852210a219c0bb" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spacetimedb" +version = "2.0.3" +dependencies = [ + "anyhow", + "bytemuck", + "bytes", + "derive_more", + "getrandom 0.2.17", + "http", + "log", + "rand 0.8.5", + "scoped-tls", + "serde_json", + "spacetimedb-bindings-macro", + "spacetimedb-bindings-sys", + "spacetimedb-lib", + "spacetimedb-primitives", + "spacetimedb-query-builder", +] + +[[package]] +name = "spacetimedb-bindings-macro" +version = "2.0.3" +dependencies = [ + "heck 0.4.1", + "humantime", + "proc-macro2", + "quote", + "spacetimedb-primitives", + "syn", +] + +[[package]] +name = "spacetimedb-bindings-sys" +version = "2.0.3" +dependencies = [ + "spacetimedb-primitives", +] + +[[package]] +name = "spacetimedb-lib" +version = "2.0.3" +dependencies = [ + "anyhow", + "bitflags", + "blake3", + "chrono", + "derive_more", + "enum-as-inner", + "hex", + "itertools", + "log", + "spacetimedb-bindings-macro", + "spacetimedb-primitives", + "spacetimedb-sats", + "thiserror", +] + +[[package]] +name = "spacetimedb-primitives" +version = "2.0.3" +dependencies = [ + "bitflags", + "either", + "enum-as-inner", + "itertools", + "nohash-hasher", +] + +[[package]] +name = "spacetimedb-query-builder" +version = "2.0.3" +dependencies = [ + "spacetimedb-lib", +] + +[[package]] +name = "spacetimedb-sats" +version = "2.0.3" +dependencies = [ + "anyhow", + "arrayvec", + "bitflags", + "bytemuck", + "bytes", + "chrono", + "decorum", + "derive_more", + "enum-as-inner", + "ethnum", + "hex", + "itertools", + "lean_string", + "rand 0.9.2", + "second-stack", + "sha3", + "smallvec", + "spacetimedb-bindings-macro", + "spacetimedb-primitives", + "thiserror", + "uuid", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/demo/ninja-game/spacetimedb/Cargo.toml b/demo/ninja-game/spacetimedb/Cargo.toml new file mode 100644 index 00000000000..318d6f3a68b --- /dev/null +++ b/demo/ninja-game/spacetimedb/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ninja-game" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb = { path = "../../../crates/bindings", version = "2.0.3" } +log = "0.4" + +[workspace] diff --git a/demo/ninja-game/spacetimedb/src/lib.rs b/demo/ninja-game/spacetimedb/src/lib.rs new file mode 100644 index 00000000000..56fe89aadfe --- /dev/null +++ b/demo/ninja-game/spacetimedb/src/lib.rs @@ -0,0 +1,745 @@ +use std::collections::HashSet; + +use spacetimedb::{log, ReducerContext, Table}; + +const WORLD_MIN: f32 = 0.0; +const WORLD_MAX: f32 = 1000.0; +const START_HEALTH: u32 = 100; +const PICKUP_RADIUS: f32 = 50.0; +const DAMAGE_PER_HIT: u32 = 5; +const HIT_COOLDOWN_MICROS: i64 = 125_000; +const MAX_PLAYERS_PER_LOBBY: usize = 30; + +#[spacetimedb::table(accessor = player, public)] +pub struct Player { + #[primary_key] + pub id: u64, + pub name: String, + pub x: f32, + pub y: f32, + pub health: u32, + pub weapon_count: u32, + pub kills: u32, + pub respawn_at_micros: i64, + pub is_ready: bool, + pub lobby_id: Option, +} + +#[spacetimedb::table(accessor = lobby, public)] +pub struct Lobby { + #[primary_key] + #[auto_inc] + pub id: u64, + pub name: String, + pub is_playing: bool, +} + +#[spacetimedb::table(accessor = weapon_drop, public)] +pub struct WeaponDrop { + #[primary_key] + #[auto_inc] + pub id: u64, + pub x: f32, + pub y: f32, + pub damage: u32, + pub lobby_id: u64, +} + +#[spacetimedb::table(accessor = bot_player)] +pub struct BotPlayer { + #[primary_key] + pub id: u64, + pub lobby_id: u64, +} + +#[spacetimedb::table(accessor = combat_hit_cooldown)] +pub struct CombatHitCooldown { + #[primary_key] + #[auto_inc] + pub id: u64, + pub attacker_id: u64, + pub target_id: u64, + pub last_hit_micros: i64, +} + +fn player_id_from_ctx(ctx: &ReducerContext) -> u64 { + let bytes = ctx.sender().to_byte_array(); + u64::from_le_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ]) +} + +fn clamp_to_world(value: f32) -> f32 { + value.clamp(WORLD_MIN, WORLD_MAX) +} + +fn normalize_name(raw: &str, fallback: &str) -> String { + let trimmed = raw.trim(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed.chars().take(24).collect() + } +} + +fn respawn_pos(seed: u64) -> (f32, f32) { + let a = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + let b = a + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + let x = 50.0 + (a >> 32) as f32 / u32::MAX as f32 * 900.0; + let y = 50.0 + (b >> 32) as f32 / u32::MAX as f32 * 900.0; + (x, y) +} + +fn lobby_player_count(ctx: &ReducerContext, lobby_id: u64) -> usize { + ctx.db + .player() + .iter() + .filter(|p| p.lobby_id == Some(lobby_id)) + .count() +} + +fn is_bot_player(ctx: &ReducerContext, player_id: u64) -> bool { + ctx.db.bot_player().id().find(player_id).is_some() +} + +fn player_row_is_bot(ctx: &ReducerContext, player: &Player) -> bool { + is_bot_player(ctx, player.id) || player.name.starts_with("Bot ") +} + +fn lobby_human_player_count(ctx: &ReducerContext, lobby_id: u64) -> usize { + ctx.db + .player() + .iter() + .filter(|p| p.lobby_id == Some(lobby_id)) + .filter(|p| !player_row_is_bot(ctx, p)) + .count() +} + +fn clear_combat_rows_for_player(ctx: &ReducerContext, player_id: u64) { + let stale_rows: Vec = ctx + .db + .combat_hit_cooldown() + .iter() + .filter(|row| row.attacker_id == player_id || row.target_id == player_id) + .map(|row| row.id) + .collect(); + for row_id in stale_rows { + ctx.db.combat_hit_cooldown().id().delete(row_id); + } +} + +fn cleanup_lobby_if_empty(ctx: &ReducerContext, lobby_id: u64) { + // A lobby should only remain alive while at least one human player is present. + if lobby_human_player_count(ctx, lobby_id) > 0 { + return; + } + + let player_ids: Vec = ctx + .db + .player() + .iter() + .filter(|p| p.lobby_id == Some(lobby_id)) + .map(|p| p.id) + .collect(); + for player_id in player_ids { + clear_combat_rows_for_player(ctx, player_id); + ctx.db.player().id().delete(player_id); + ctx.db.bot_player().id().delete(player_id); + } + + let weapon_ids: Vec = ctx + .db + .weapon_drop() + .iter() + .filter(|w| w.lobby_id == lobby_id) + .map(|w| w.id) + .collect(); + for wid in weapon_ids { + ctx.db.weapon_drop().id().delete(wid); + } + + let bot_ids: Vec = ctx + .db + .bot_player() + .iter() + .filter(|b| b.lobby_id == lobby_id) + .map(|b| b.id) + .collect(); + for bot_id in bot_ids { + ctx.db.bot_player().id().delete(bot_id); + } + + ctx.db.lobby().id().delete(lobby_id); +} + +fn remove_player(ctx: &ReducerContext, player_id: u64) { + if let Some(player) = ctx.db.player().id().find(player_id) { + let lobby_id = player.lobby_id; + ctx.db.player().id().delete(player_id); + clear_combat_rows_for_player(ctx, player_id); + ctx.db.bot_player().id().delete(player_id); + if let Some(lobby_id) = lobby_id { + cleanup_lobby_if_empty(ctx, lobby_id); + } + } +} + +fn upsert_player_name(ctx: &ReducerContext, player_id: u64, name: String) { + if let Some(mut existing) = ctx.db.player().id().find(player_id) { + existing.name = name; + ctx.db.player().id().update(existing); + return; + } + + ctx.db.player().insert(Player { + id: player_id, + name, + x: 500.0, + y: 500.0, + health: START_HEALTH, + weapon_count: 0, + kills: 0, + respawn_at_micros: 0, + is_ready: false, + lobby_id: None, + }); +} + +fn apply_combat(ctx: &ReducerContext, attacker_id: u64, target_id: u64) { + if attacker_id == target_id { + return; + } + + let Some(attacker_current) = ctx.db.player().id().find(attacker_id) else { + return; + }; + if attacker_current.weapon_count == 0 || attacker_current.health == 0 { + return; + } + + let Some(mut target) = ctx.db.player().id().find(target_id) else { + return; + }; + if target.health == 0 { + return; + } + + let Some(lobby_id) = attacker_current.lobby_id else { + return; + }; + if target.lobby_id != Some(lobby_id) { + return; + } + let Some(lobby) = ctx.db.lobby().id().find(lobby_id) else { + return; + }; + if !lobby.is_playing { + return; + } + + let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); + let existing_cooldown = ctx + .db + .combat_hit_cooldown() + .iter() + .find(|row| row.attacker_id == attacker_id && row.target_id == target_id); + + if let Some(mut cooldown_row) = existing_cooldown { + if now_micros - cooldown_row.last_hit_micros < HIT_COOLDOWN_MICROS { + return; + } + cooldown_row.last_hit_micros = now_micros; + ctx.db.combat_hit_cooldown().id().update(cooldown_row); + } else { + ctx.db.combat_hit_cooldown().insert(CombatHitCooldown { + id: 0, + attacker_id, + target_id, + last_hit_micros: now_micros, + }); + } + + let mut attacker = attacker_current; + let target_is_bot = player_row_is_bot(ctx, &target); + if target.health <= DAMAGE_PER_HIT { + attacker.kills += 1; + if target_is_bot { + // Bots are removed from the match when killed and do not respawn. + ctx.db.player().id().update(attacker); + remove_player(ctx, target_id); + return; + } + target.health = 0; + target.weapon_count = 0; + target.respawn_at_micros = now_micros + 2_000_000; + } else { + target.health -= DAMAGE_PER_HIT; + } + + ctx.db.player().id().update(attacker); + ctx.db.player().id().update(target); +} + +#[spacetimedb::reducer] +pub fn set_name(ctx: &ReducerContext, name: String) { + let player_id = player_id_from_ctx(ctx); + let fallback = format!("Player {:04X}", player_id & 0xFFFF); + let normalized = normalize_name(&name, &fallback); + upsert_player_name(ctx, player_id, normalized); + + // If the player isn't in a lobby yet, auto-assign them to one. + // This allows "Quick Play" from the title screen by just setting a name. + let mut player = ctx.db.player().id().find(player_id).unwrap(); + if player.lobby_id.is_none() { + let lobby_id = if let Some(l) = ctx + .db + .lobby() + .iter() + .find(|l| !l.is_playing && lobby_player_count(ctx, l.id) < MAX_PLAYERS_PER_LOBBY) + { + l.id + } else if let Some(l) = ctx + .db + .lobby() + .iter() + .find(|l| lobby_player_count(ctx, l.id) < MAX_PLAYERS_PER_LOBBY) + { + l.id + } else { + let lobby_name = format!("{}'s Lobby", player.name); + ctx.db + .lobby() + .insert(Lobby { + id: 0, + name: lobby_name, + is_playing: false, + }) + .id + }; + player.lobby_id = Some(lobby_id); + ctx.db.player().id().update(player); + } +} + +#[spacetimedb::reducer] +pub fn join(ctx: &ReducerContext, name: String) { + set_name(ctx, name); +} + +#[spacetimedb::reducer] +pub fn leave(ctx: &ReducerContext) { + let player_id = player_id_from_ctx(ctx); + remove_player(ctx, player_id); +} + +#[spacetimedb::reducer] +pub fn create_lobby(ctx: &ReducerContext, name: String) { + let player_id = player_id_from_ctx(ctx); + if ctx.db.player().id().find(player_id).is_none() { + let fallback = format!("Player {:04X}", player_id & 0xFFFF); + upsert_player_name(ctx, player_id, fallback); + } + + let Some(mut player) = ctx.db.player().id().find(player_id) else { + return; + }; + if player.lobby_id.is_some() { + return; + } + + let lobby_name = normalize_name(&name, "Quick Lobby"); + let lobby = ctx.db.lobby().insert(Lobby { + id: 0, + name: lobby_name, + is_playing: false, + }); + + player.lobby_id = Some(lobby.id); + player.is_ready = false; + player.health = START_HEALTH; + player.weapon_count = 0; + player.respawn_at_micros = 0; + player.x = 500.0; + player.y = 500.0; + ctx.db.player().id().update(player); +} + +#[spacetimedb::reducer] +pub fn join_lobby(ctx: &ReducerContext, lobby_id: u64) { + let player_id = player_id_from_ctx(ctx); + if ctx.db.player().id().find(player_id).is_none() { + let fallback = format!("Player {:04X}", player_id & 0xFFFF); + upsert_player_name(ctx, player_id, fallback); + } + + let Some(mut player) = ctx.db.player().id().find(player_id) else { + return; + }; + if player.lobby_id.is_some() { + return; + } + + if ctx.db.lobby().id().find(lobby_id).is_none() { + return; + } + if lobby_player_count(ctx, lobby_id) >= MAX_PLAYERS_PER_LOBBY { + return; + } + + player.lobby_id = Some(lobby_id); + player.is_ready = false; + player.health = START_HEALTH; + player.weapon_count = 0; + player.respawn_at_micros = 0; + player.x = 500.0; + player.y = 500.0; + ctx.db.player().id().update(player); +} + +#[spacetimedb::reducer] +pub fn leave_lobby(ctx: &ReducerContext) { + let player_id = player_id_from_ctx(ctx); + let Some(mut player) = ctx.db.player().id().find(player_id) else { + return; + }; + let Some(lobby_id) = player.lobby_id else { + return; + }; + + player.lobby_id = None; + player.is_ready = false; + player.health = START_HEALTH; + player.weapon_count = 0; + player.respawn_at_micros = 0; + player.x = 500.0; + player.y = 500.0; + ctx.db.player().id().update(player); + clear_combat_rows_for_player(ctx, player_id); + + cleanup_lobby_if_empty(ctx, lobby_id); +} + +#[spacetimedb::reducer] +pub fn toggle_ready(ctx: &ReducerContext) { + let player_id = player_id_from_ctx(ctx); + let Some(mut player) = ctx.db.player().id().find(player_id) else { + return; + }; + let Some(lobby_id) = player.lobby_id else { + return; + }; + let Some(lobby) = ctx.db.lobby().id().find(lobby_id) else { + return; + }; + if lobby.is_playing { + return; + } + + player.is_ready = !player.is_ready; + ctx.db.player().id().update(player); +} + +#[spacetimedb::reducer] +pub fn start_match(ctx: &ReducerContext) { + let caller_id = player_id_from_ctx(ctx); + let Some(caller) = ctx.db.player().id().find(caller_id) else { + return; + }; + let Some(lobby_id) = caller.lobby_id else { + return; + }; + + let Some(mut lobby) = ctx.db.lobby().id().find(lobby_id) else { + return; + }; + if lobby.is_playing { + return; + } + lobby.is_playing = true; + ctx.db.lobby().id().update(lobby); + + let now = ctx.timestamp.to_micros_since_unix_epoch() as u64; + let players: Vec = ctx + .db + .player() + .iter() + .filter(|p| p.lobby_id == Some(lobby_id)) + .collect(); + for mut p in players { + let (x, y) = respawn_pos(p.id.wrapping_add(now)); + p.x = x; + p.y = y; + p.health = START_HEALTH; + p.weapon_count = 0; + p.respawn_at_micros = 0; + p.is_ready = false; + ctx.db.player().id().update(p); + } + + let weapon_ids: Vec = ctx + .db + .weapon_drop() + .iter() + .filter(|w| w.lobby_id == lobby_id) + .map(|w| w.id) + .collect(); + for wid in weapon_ids { + ctx.db.weapon_drop().id().delete(wid); + } +} + +#[spacetimedb::reducer] +pub fn end_match(ctx: &ReducerContext) { + let caller_id = player_id_from_ctx(ctx); + let Some(caller) = ctx.db.player().id().find(caller_id) else { + return; + }; + let Some(lobby_id) = caller.lobby_id else { + return; + }; + + // If caller is the last human, ending the match should dissolve the session. + if lobby_human_player_count(ctx, lobby_id) <= 1 { + remove_player(ctx, caller_id); + return; + } + + if let Some(mut lobby) = ctx.db.lobby().id().find(lobby_id) { + lobby.is_playing = false; + ctx.db.lobby().id().update(lobby); + } + + let mut bot_ids: HashSet = ctx + .db + .bot_player() + .iter() + .filter(|b| b.lobby_id == lobby_id) + .map(|b| b.id) + .collect(); + for p in ctx.db.player().iter().filter(|p| p.lobby_id == Some(lobby_id)) { + if player_row_is_bot(ctx, &p) { + bot_ids.insert(p.id); + } + } + + let players: Vec = ctx + .db + .player() + .iter() + .filter(|p| p.lobby_id == Some(lobby_id)) + .collect(); + let player_ids: HashSet = players.iter().map(|p| p.id).collect(); + for mut p in players { + if bot_ids.contains(&p.id) { + ctx.db.player().id().delete(p.id); + continue; + } + p.is_ready = false; + p.health = START_HEALTH; + p.weapon_count = 0; + p.respawn_at_micros = 0; + ctx.db.player().id().update(p); + } + + let weapon_ids: Vec = ctx + .db + .weapon_drop() + .iter() + .filter(|w| w.lobby_id == lobby_id) + .map(|w| w.id) + .collect(); + for wid in weapon_ids { + ctx.db.weapon_drop().id().delete(wid); + } + + let cooldown_ids: Vec = ctx + .db + .combat_hit_cooldown() + .iter() + .filter(|row| player_ids.contains(&row.attacker_id) || player_ids.contains(&row.target_id)) + .map(|row| row.id) + .collect(); + for cid in cooldown_ids { + ctx.db.combat_hit_cooldown().id().delete(cid); + } + + for bot_id in bot_ids { + ctx.db.bot_player().id().delete(bot_id); + } +} + +#[spacetimedb::reducer] +pub fn spawn_test_player(ctx: &ReducerContext) { + let caller_id = player_id_from_ctx(ctx); + let Some(caller) = ctx.db.player().id().find(caller_id) else { + return; + }; + let Some(lobby_id) = caller.lobby_id else { + return; + }; + let Some(lobby) = ctx.db.lobby().id().find(lobby_id) else { + return; + }; + if !lobby.is_playing { + return; + } + if lobby_player_count(ctx, lobby_id) >= MAX_PLAYERS_PER_LOBBY { + return; + } + + let now = ctx.timestamp.to_micros_since_unix_epoch() as u64; + let mut bot_id = now.wrapping_mul(1103515245).wrapping_add(12345); + while ctx.db.player().id().find(bot_id).is_some() { + bot_id = bot_id.wrapping_add(1); + } + + let bot_name = format!("Bot {}", bot_id % 10_000); + let (x, y) = respawn_pos(bot_id.wrapping_add(now)); + ctx.db.player().insert(Player { + id: bot_id, + name: bot_name, + x, + y, + health: START_HEALTH, + weapon_count: 0, + kills: 0, + respawn_at_micros: 0, + is_ready: true, + lobby_id: Some(lobby_id), + }); + ctx.db.bot_player().insert(BotPlayer { id: bot_id, lobby_id }); +} + +#[spacetimedb::reducer] +pub fn move_player(ctx: &ReducerContext, x: f32, y: f32) { + let player_id = player_id_from_ctx(ctx); + let Some(mut player) = ctx.db.player().id().find(player_id) else { + return; + }; + if player.health == 0 { + return; + } + + player.x = clamp_to_world(x); + player.y = clamp_to_world(y); + + if let Some(lobby_id) = player.lobby_id { + let nearby: Vec = ctx + .db + .weapon_drop() + .iter() + .filter(|w| w.lobby_id == lobby_id) + .filter(|w| { + let dx = player.x - w.x; + let dy = player.y - w.y; + dx * dx + dy * dy < PICKUP_RADIUS * PICKUP_RADIUS + }) + .map(|w| w.id) + .collect(); + + if !nearby.is_empty() { + for wid in &nearby { + ctx.db.weapon_drop().id().delete(*wid); + } + player.weapon_count += nearby.len() as u32; + } + } + + ctx.db.player().id().update(player); +} + +#[spacetimedb::reducer] +pub fn attack(ctx: &ReducerContext, target_id: u64) { + let attacker_id = player_id_from_ctx(ctx); + apply_combat(ctx, attacker_id, target_id); +} + +#[spacetimedb::reducer] +pub fn spawn_weapon(ctx: &ReducerContext, x: f32, y: f32) { + let player_id = player_id_from_ctx(ctx); + let Some(player) = ctx.db.player().id().find(player_id) else { + return; + }; + let Some(lobby_id) = player.lobby_id else { + return; + }; + + let Some(lobby) = ctx.db.lobby().id().find(lobby_id) else { + return; + }; + if !lobby.is_playing { + return; + } + + ctx.db.weapon_drop().insert(WeaponDrop { + id: 0, + x: clamp_to_world(x), + y: clamp_to_world(y), + damage: DAMAGE_PER_HIT, + lobby_id, + }); +} + +#[spacetimedb::reducer] +pub fn respawn(ctx: &ReducerContext) { + let player_id = player_id_from_ctx(ctx); + let Some(mut player) = ctx.db.player().id().find(player_id) else { + return; + }; + if player.health > 0 { + return; + } + + let now = ctx.timestamp.to_micros_since_unix_epoch(); + if player.respawn_at_micros > now { + return; + } + + let (x, y) = respawn_pos(player_id.wrapping_add(now as u64)); + player.x = x; + player.y = y; + player.health = START_HEALTH; + player.respawn_at_micros = 0; + ctx.db.player().id().update(player); +} + +#[spacetimedb::reducer] +pub fn clear_server(ctx: &ReducerContext) { + let player_ids: Vec = ctx.db.player().iter().map(|p| p.id).collect(); + let lobby_ids: Vec = ctx.db.lobby().iter().map(|l| l.id).collect(); + let weapon_ids: Vec = ctx.db.weapon_drop().iter().map(|w| w.id).collect(); + let bot_ids: Vec = ctx.db.bot_player().iter().map(|b| b.id).collect(); + let cooldown_ids: Vec = ctx + .db + .combat_hit_cooldown() + .iter() + .map(|row| row.id) + .collect(); + + for player_id in player_ids { + ctx.db.player().id().delete(player_id); + } + for lobby_id in lobby_ids { + ctx.db.lobby().id().delete(lobby_id); + } + for weapon_id in weapon_ids { + ctx.db.weapon_drop().id().delete(weapon_id); + } + for bot_id in bot_ids { + ctx.db.bot_player().id().delete(bot_id); + } + for cooldown_id in cooldown_ids { + ctx.db.combat_hit_cooldown().id().delete(cooldown_id); + } + log::info!("Server state forcefully cleared"); +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn client_disconnected(ctx: &ReducerContext) { + let player_id = player_id_from_ctx(ctx); + remove_player(ctx, player_id); +} diff --git a/demo/simple-module/README.md b/demo/simple-module/README.md new file mode 100644 index 00000000000..fd9a0c28c08 --- /dev/null +++ b/demo/simple-module/README.md @@ -0,0 +1,88 @@ +# Simple Module Swift Demo + +This demo provides a minimal but polished SwiftUI client wired to a SpacetimeDB module, with both local and Maincloud (spacetimedb.com) test flows. + +## Layout + +- `spacetimedb/`: Rust module with `person` table and reducers: + - `add(name)` + - `delete_person(id)` +- `client-swift/`: SwiftUI app that connects, invokes reducers, and renders the replicated `person` table + - Includes macOS-local setup controls: + - `Start Local Server` + - `Publish Module` + - `Bootstrap Local` (starts server, publishes module, then connects) + - Includes Maincloud test controls: + - `Use Maincloud Preset` + - `Publish Maincloud Module` + - `Load CLI Token` (optional) + +## Run It + +1. Start SpacetimeDB: + + ```bash + spacetime start + ``` + +2. Publish the module: + + ```bash + spacetime publish -s local -p demo/simple-module/spacetimedb simple-module-demo -c -y + ``` + +3. Run the Swift client: + + ```bash + cd demo/simple-module/client-swift + swift run + ``` + +The app defaults to: + +- Server URL: `http://127.0.0.1:3000` +- Database name: `simple-module-demo` + +On macOS, the app has an in-app numbered flow: + +1. Step 1: `Start Local Server` +2. Step 2: `Publish Module` +3. Step 3: `Connect` +4. Add names to verify live replication +5. Delete names from the row `trash` button to verify replicated deletes + +You can also click `Bootstrap Local (Recommended)` to run steps 1-3 automatically. + +## Maincloud (spacetimedb.com) Test + +1. Log in to SpacetimeDB CLI: + + ```bash + spacetime login + ``` + +2. Publish this module to Maincloud: + + ```bash + spacetime publish -s maincloud -p demo/simple-module/spacetimedb simple-module-demo -c -y + ``` + +3. Run the Swift client (`cd demo/simple-module/client-swift && swift run`), then in-app: + + - Step 1: `Use Maincloud Preset` + - Step 2: `Publish Maincloud Module` (optional if already published via terminal) + - Optional: `Load CLI Token` + - Step 3: `Connect to Maincloud` + - Add and delete names to verify replication on Maincloud + +## Regenerate Swift Bindings + +From repo root: + +```bash +cargo run -p spacetimedb-cli -- generate \ + --lang swift \ + --out-dir demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated \ + --module-path demo/simple-module/spacetimedb \ + --no-config +``` diff --git a/demo/simple-module/client-swift/.gitignore b/demo/simple-module/client-swift/.gitignore new file mode 100644 index 00000000000..0023a534063 --- /dev/null +++ b/demo/simple-module/client-swift/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/demo/simple-module/client-swift/Package.swift b/demo/simple-module/client-swift/Package.swift new file mode 100644 index 00000000000..87fa515d264 --- /dev/null +++ b/demo/simple-module/client-swift/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "SimpleModuleClient", + platforms: [ + .macOS(.v15), + .iOS(.v17) + ], + products: [ + .executable( + name: "SimpleModuleClient", + targets: ["SimpleModuleClient"] + ) + ], + dependencies: [ + .package(name: "SpacetimeDB", path: "../../../sdks/swift") + ], + targets: [ + .executableTarget( + name: "SimpleModuleClient", + dependencies: [ + .product(name: "SpacetimeDB", package: "SpacetimeDB") + ] + ) + ] +) diff --git a/demo/simple-module/client-swift/README.md b/demo/simple-module/client-swift/README.md new file mode 100644 index 00000000000..1a9f9b60feb --- /dev/null +++ b/demo/simple-module/client-swift/README.md @@ -0,0 +1,59 @@ +# SimpleModuleClient + +SwiftUI client for the `demo/simple-module/spacetimedb` module. + +## Run (Xcode or SwiftPM) + +Open package in Xcode: + +```bash +open Package.swift +``` + +or run from terminal: + +```bash +swift run +``` + +If your module/database name differs from `simple-module-demo`, update the value in the app UI before connecting. + +## Local Quick Start (Step 1/2/3) + +In the app: + +1. `Use Local Preset` +2. `Start Local Server` +3. `Publish Module` +4. `Connect` + +Then test realtime reducers: + +- `Add` +- `Add Sample` +- `Delete` (trash button per row) + +`Bootstrap Local (Recommended)` runs steps 2-4 automatically. + +## Maincloud Quick Test + +1. `Use Maincloud Preset` +2. (Optional) `Load CLI Token` after running `spacetime login` +3. `Publish Maincloud Module` +4. `Connect` + +## Troubleshooting + +- `bad server response` on connect: + publish the module for that server, then reconnect. +- `Reducer ... no such reducer`: + server schema is stale for this database; publish again, then reconnect. +- local publish fails: + confirm `demo/simple-module/spacetimedb` exists and `spacetime` CLI is installed. + +## What You Can Test + +- Add a person (`add` reducer) +- Delete a person from the list (`delete_person` reducer) +- Add sample rows quickly with `Add Sample` (UI action that calls `add`) +- Test against local server (`http://127.0.0.1:3000`) or Maincloud (`https://maincloud.spacetimedb.com`) diff --git a/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/Add.swift b/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/Add.swift new file mode 100644 index 00000000000..2d73d565465 --- /dev/null +++ b/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/Add.swift @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB + +public enum Add { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + public var name: String + + public func encodeBSATN(to storage: BSATNStorage) throws { + try storage.appendString(self.name) + } + } + + @MainActor public static func invoke(name: String) { + let args = _Args( + name: name + ) + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("add", argBytes) + } catch { + print("Failed to encode Add arguments: \(error)") + } + } +} diff --git a/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/DeletePerson.swift b/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/DeletePerson.swift new file mode 100644 index 00000000000..69d6a1432f8 --- /dev/null +++ b/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/DeletePerson.swift @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB + +public enum DeletePerson { + public struct _Args: Codable, Sendable, BSATNSpecialEncodable { + public var id: UInt64 + + public func encodeBSATN(to storage: BSATNStorage) throws { + storage.appendU64(self.id) + } + } + + @MainActor public static func invoke(id: UInt64) { + let args = _Args( + id: id + ) + do { + let argBytes = try BSATNEncoder().encode(args) + SpacetimeClient.shared?.send("delete_person", argBytes) + } catch { + print("Failed to encode DeletePerson arguments: \(error)") + } + } +} diff --git a/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/Person.swift b/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/Person.swift new file mode 100644 index 00000000000..c33ae73b915 --- /dev/null +++ b/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/Person.swift @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB + +public struct Person: Codable, Sendable, BSATNSpecialDecodable, BSATNSpecialEncodable { + public var id: UInt64 + public var name: String + public var createdAtMicros: Int64 + public var createdByHex: String + + public static func decodeBSATN(from reader: BSATNReader) throws -> Person { + return Person( + id: try reader.readU64(), + name: try reader.readString(), + createdAtMicros: try reader.readI64(), + createdByHex: try reader.readString() + ) + } + + public func encodeBSATN(to storage: BSATNStorage) throws { + storage.appendU64(self.id) + try storage.appendString(self.name) + storage.appendI64(self.createdAtMicros) + try storage.appendString(self.createdByHex) + } +} diff --git a/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/PersonTable.swift b/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/PersonTable.swift new file mode 100644 index 00000000000..c0767ffb9bd --- /dev/null +++ b/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/PersonTable.swift @@ -0,0 +1,11 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB + +public struct PersonTable { + @MainActor public static var cache: TableCache { + return SpacetimeClient.clientCache.getTableCache(tableName: "person") + } +} diff --git a/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/SpacetimeModule.swift b/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/SpacetimeModule.swift new file mode 100644 index 00000000000..78e05fe5b9c --- /dev/null +++ b/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated/SpacetimeModule.swift @@ -0,0 +1,11 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +import Foundation +import SpacetimeDB + +public enum SpacetimeModule { + @MainActor public static func registerTables() { + SpacetimeClient.clientCache.registerTable(tableName: "person", rowType: Person.self) + } +} diff --git a/demo/simple-module/client-swift/Sources/SimpleModuleClient/SDKCompat.swift b/demo/simple-module/client-swift/Sources/SimpleModuleClient/SDKCompat.swift new file mode 100644 index 00000000000..a9db768eeff --- /dev/null +++ b/demo/simple-module/client-swift/Sources/SimpleModuleClient/SDKCompat.swift @@ -0,0 +1,5 @@ +import SpacetimeDB + +public typealias BSATNEncoder = SpacetimeDB.BSATNEncoder +public typealias SpacetimeClient = SpacetimeDB.SpacetimeClient +public typealias TableCache = SpacetimeDB.TableCache diff --git a/demo/simple-module/client-swift/Sources/SimpleModuleClient/SimpleModuleApp.swift b/demo/simple-module/client-swift/Sources/SimpleModuleClient/SimpleModuleApp.swift new file mode 100644 index 00000000000..154c1e034a9 --- /dev/null +++ b/demo/simple-module/client-swift/Sources/SimpleModuleClient/SimpleModuleApp.swift @@ -0,0 +1,1095 @@ +import SwiftUI +import Observation +import SpacetimeDB +import Foundation +#if canImport(AppKit) +import AppKit +#endif + +#if canImport(AppKit) +private struct ShellCommandResult { + let exitCode: Int32 + let output: String +} +#endif + +@MainActor +@Observable +final class SimpleModuleViewModel: SpacetimeClientDelegate { + var serverURL: String = "http://127.0.0.1:3000" + var databaseName: String = "simple-module-demo" + var connectToken: String = "" + var draftName: String = "" + + var isConnected: Bool = false + var isConnecting: Bool = false + var identityHex: String = "-" + var tokenPreview: String = "-" + var statusMessage: String = "Disconnected" + var isLocalActionRunning: Bool = false + var localServerReachable: Bool = false + var modulePublishedLocally: Bool = false + var modulePublishedOnMaincloud: Bool = false + var lastConnectBadResponse: Bool = false + var localSetupLog: String = "" + + var people: [Person] = [] + + private var client: SpacetimeClient? + private var savedToken: String? + #if canImport(AppKit) + private var localServerProcess: Process? + #endif + + init() { + SpacetimeModule.registerTables() + #if canImport(AppKit) + Task { await refreshLocalServerStatus() } + #endif + } + + func connect() { + guard let url = URL(string: serverURL) else { + statusMessage = "Invalid server URL" + return + } + + disconnect(clearStatus: false) + PersonTable.cache.clear() + people = [] + statusMessage = "Connecting..." + isConnecting = true + lastConnectBadResponse = false + + let client = SpacetimeClient(serverUrl: url, moduleName: databaseName) + self.client = client + SpacetimeClient.shared = client + client.delegate = self + let manualToken = connectToken.trimmingCharacters(in: .whitespacesAndNewlines) + let tokenToUse = manualToken.isEmpty ? savedToken : manualToken + if !manualToken.isEmpty { + tokenPreview = previewToken(manualToken) + } + client.connect(token: tokenToUse) + } + + func disconnect(clearStatus: Bool = true) { + isConnecting = false + isConnected = false + client?.delegate = nil + client?.disconnect() + client = nil + SpacetimeClient.shared = nil + if clearStatus { + statusMessage = "Disconnected" + } + } + + func addPerson() { + let trimmed = draftName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + statusMessage = "Name cannot be empty" + return + } + guard isConnected else { + statusMessage = "Connect before sending reducers" + return + } + + Add.invoke(name: trimmed) + draftName = "" + } + + func deletePerson(id: UInt64) { + guard isConnected else { + statusMessage = "Connect before sending reducers" + return + } + DeletePerson.invoke(id: id) + statusMessage = "Sent `delete_person` for row #\(id)" + } + + func addSamplePerson() { + guard isConnected else { + statusMessage = "Connect before sending reducers" + return + } + let sampleNames = ["Alex", "Sam", "Riley", "Jordan", "Taylor", "Casey"] + let idx = Int(Date().timeIntervalSince1970) % sampleNames.count + let name = "\(sampleNames[idx]) \(people.count + 1)" + Add.invoke(name: name) + statusMessage = "Added sample person: \(name)" + } + + func clearLocalReplica() { + PersonTable.cache.clear() + people = [] + } + + func onConnect() { + isConnecting = false + isConnected = true + if isUsingMaincloud { + modulePublishedOnMaincloud = true + } else { + modulePublishedLocally = true + } + lastConnectBadResponse = false + statusMessage = "Connected" + } + + func onDisconnect(error: Error?) { + isConnecting = false + isConnected = false + if let error { + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorBadServerResponse { + if isUsingMaincloud { + modulePublishedOnMaincloud = false + } else { + modulePublishedLocally = false + } + lastConnectBadResponse = true + statusMessage = "Disconnected: bad server response. Publish '\(databaseName)' on this server, then reconnect." + } else { + statusMessage = "Disconnected: \(error.localizedDescription)" + } + } else { + statusMessage = "Disconnected" + } + } + + func onIdentityReceived(identity: [UInt8], token: String) { + identityHex = identity.map { String(format: "%02x", $0) }.joined() + savedToken = token + tokenPreview = previewToken(token) + } + + func onTransactionUpdate(message: Data?) { + people = PersonTable.cache.rows.sorted { lhs, rhs in + if lhs.createdAtMicros == rhs.createdAtMicros { + return lhs.id > rhs.id + } + return lhs.createdAtMicros > rhs.createdAtMicros + } + } + + func onReducerError(reducer: String, message: String, isInternal: Bool) { + let lowered = message.lowercased() + if lowered.contains("no such reducer") { + if isUsingMaincloud { + modulePublishedOnMaincloud = false + } else { + modulePublishedLocally = false + } + statusMessage = "Reducer '\(reducer)' is missing on server. Republish '\(databaseName)' then reconnect." + } else { + let scope = isInternal ? "internal reducer error" : "reducer error" + statusMessage = "\(scope) for '\(reducer)': \(message)" + } + #if canImport(AppKit) + appendLocalLog("[Reducer:\(reducer)] \(message)") + #endif + } + + func clearLocalSetupLog() { + localSetupLog = "" + } + + var localNextStepText: String { + if !isLocalServerTarget { + return "Set server URL to localhost or 127.0.0.1 for local flow." + } + if isConnected { + return "Ready: Step 4 add names and watch replicated rows update." + } + if !localServerReachable { + return "Next: Step 1 (Start Local Server)." + } + if !modulePublishedLocally || lastConnectBadResponse { + return "Next: Step 2 (Publish Module)." + } + return "Next: Step 3 (Connect)." + } + + var isUsingMaincloud: Bool { + guard let host = URLComponents(string: serverURL)?.host?.lowercased() else { return false } + return host == "maincloud.spacetimedb.com" + } + + var isLocalServerTarget: Bool { + guard let host = URLComponents(string: serverURL)?.host?.lowercased() else { return false } + return host == "127.0.0.1" || host == "localhost" + } + + var maincloudNextStepText: String { + if !isUsingMaincloud { + return "Next: Step 1 (Use Maincloud preset)." + } + if isConnected { + return "Ready: Connected to Maincloud." + } + if !modulePublishedOnMaincloud || lastConnectBadResponse { + return "Next: Step 2 (Publish module to Maincloud)." + } + return "Next: Step 3 (Connect)." + } + + func startLocalServer() { + #if canImport(AppKit) + Task { await startLocalServerTask() } + #else + statusMessage = "Local server controls are only available on macOS." + #endif + } + + func publishLocalModule() { + #if canImport(AppKit) + Task { await publishLocalModuleTask() } + #else + statusMessage = "Local module publish is only available on macOS." + #endif + } + + func bootstrapLocalDemo() { + #if canImport(AppKit) + Task { await bootstrapLocalDemoTask() } + #else + statusMessage = "Local demo bootstrap is only available on macOS." + #endif + } + + func useLocalPreset() { + serverURL = "http://127.0.0.1:3000" + lastConnectBadResponse = false + statusMessage = "Local preset applied." + #if canImport(AppKit) + Task { _ = await refreshLocalServerStatus() } + #endif + } + + func useMaincloudPreset() { + serverURL = "https://maincloud.spacetimedb.com" + lastConnectBadResponse = false + statusMessage = "Maincloud preset applied." + } + + func publishMaincloudModule() { + #if canImport(AppKit) + Task { await publishMaincloudModuleTask() } + #else + statusMessage = "Maincloud module publish is only available on macOS." + #endif + } + + func loadCLIToken() { + #if canImport(AppKit) + Task { await loadCLITokenTask() } + #else + statusMessage = "CLI token loading is only available on macOS." + #endif + } + + #if canImport(AppKit) + private func bootstrapLocalDemoTask() async { + guard !isLocalActionRunning else { + statusMessage = "A local setup action is already running." + return + } + isLocalActionRunning = true + defer { isLocalActionRunning = false } + + appendLocalLog("== Bootstrap local demo ==") + let started = await ensureLocalServerRunning() + guard started else { return } + let published = await publishLocalModuleInternal() + guard published else { return } + statusMessage = "Local demo is ready. Connecting..." + connect() + } + + private func startLocalServerTask() async { + guard !isLocalActionRunning else { + statusMessage = "A local setup action is already running." + return + } + isLocalActionRunning = true + defer { isLocalActionRunning = false } + _ = await ensureLocalServerRunning() + } + + private func publishLocalModuleTask() async { + guard !isLocalActionRunning else { + statusMessage = "A local setup action is already running." + return + } + isLocalActionRunning = true + defer { isLocalActionRunning = false } + + if !(await refreshLocalServerStatus()) { + statusMessage = "Local server is not reachable. Start it first." + return + } + _ = await publishLocalModuleInternal() + } + + private func publishMaincloudModuleTask() async { + guard !isLocalActionRunning else { + statusMessage = "A setup action is already running." + return + } + isLocalActionRunning = true + defer { isLocalActionRunning = false } + + let published = await publishModuleInternal(server: "maincloud") + if published { + modulePublishedOnMaincloud = true + } + } + + private func ensureLocalServerRunning() async -> Bool { + if await refreshLocalServerStatus() { + statusMessage = "Local server already running." + appendLocalLog("Local server already reachable at \(serverURL)") + return true + } + + guard localServerProcess?.isRunning != true else { + statusMessage = "Local server process already started. Waiting for it..." + appendLocalLog("Local server process is already running.") + try? await Task.sleep(for: .seconds(1)) + return await refreshLocalServerStatus() + } + + guard let listenAddress = localListenAddress() else { + statusMessage = "Server URL must be localhost/127.0.0.1 for local server start." + appendLocalLog("Refused start: non-local server URL '\(serverURL)'") + return false + } + + appendLocalLog("Starting local server on \(listenAddress)...") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-lc", "spacetime start --non-interactive --listen-addr \(shellQuote(listenAddress))"] + + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = outputPipe + + outputPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } + Task { @MainActor in + self?.appendLocalLog(text) + } + } + + process.terminationHandler = { [weak self] process in + Task { @MainActor in + self?.appendLocalLog("Local server exited with code \(process.terminationStatus).") + self?.localServerProcess = nil + _ = await self?.refreshLocalServerStatus() + } + } + + do { + try process.run() + localServerProcess = process + } catch { + statusMessage = "Failed to start local server: \(error.localizedDescription)" + appendLocalLog("Failed to start server: \(error.localizedDescription)") + return false + } + + try? await Task.sleep(for: .seconds(1)) + if await refreshLocalServerStatus() { + statusMessage = "Local server started." + appendLocalLog("Local server is reachable.") + return true + } + + statusMessage = "Started server process, but it is not reachable yet." + return false + } + + private func publishLocalModuleInternal() async -> Bool { + await publishModuleInternal(server: "local") + } + + private func publishModuleInternal(server: String) async -> Bool { + let modulePath = localModulePathURL.path + guard FileManager.default.fileExists(atPath: modulePath) else { + statusMessage = "Module path not found." + appendLocalLog("Missing module path: \(modulePath)") + return false + } + + appendLocalLog("Publishing module '\(databaseName)' to '\(server)' from \(modulePath)") + let cmd = "spacetime publish -s \(shellQuote(server)) -p \(shellQuote(modulePath)) \(shellQuote(databaseName)) -c -y" + let result = await runShellCommand(cmd, currentDirectory: repoRootURL) + if !result.output.isEmpty { + appendLocalLog(result.output) + } + + if result.exitCode == 0 { + if server == "maincloud" { + modulePublishedOnMaincloud = true + } else { + modulePublishedLocally = true + } + lastConnectBadResponse = false + statusMessage = "Published module '\(databaseName)' to '\(server)'." + return true + } else { + statusMessage = "Module publish failed for '\(server)' (exit \(result.exitCode))." + return false + } + } + + private func loadCLITokenTask() async { + guard !isLocalActionRunning else { + statusMessage = "A setup action is already running." + return + } + isLocalActionRunning = true + defer { isLocalActionRunning = false } + + let result = await runShellCommand("spacetime login show --token", currentDirectory: repoRootURL) + guard result.exitCode == 0 else { + if !result.output.isEmpty { + appendLocalLog(result.output) + } + statusMessage = "Failed to read CLI token. Run `spacetime login` in Terminal first." + return + } + guard let token = parseCLIToken(from: result.output) else { + statusMessage = "Could not parse token from CLI output." + return + } + appendLocalLog("Loaded auth token from local CLI login.") + connectToken = token + savedToken = token + tokenPreview = previewToken(token) + statusMessage = "Loaded CLI auth token." + } + + private func refreshLocalServerStatus() async -> Bool { + guard let pingURL = pingURL() else { + localServerReachable = false + return false + } + + var request = URLRequest(url: pingURL) + request.timeoutInterval = 1.5 + request.cachePolicy = .reloadIgnoringLocalCacheData + + do { + let (_, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) { + localServerReachable = true + return true + } + } catch { + // fallthrough + } + + localServerReachable = false + return false + } + + private func pingURL() -> URL? { + guard var comps = URLComponents(string: serverURL) else { return nil } + comps.path = "/v1/ping" + comps.query = nil + return comps.url + } + + private var repoRootURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // SimpleModuleClient + .deletingLastPathComponent() // Sources + .deletingLastPathComponent() // client-swift + .deletingLastPathComponent() // simple-module + .deletingLastPathComponent() // demo + .deletingLastPathComponent() // repo root + } + + private var localModulePathURL: URL { + repoRootURL.appendingPathComponent("demo/simple-module/spacetimedb", isDirectory: true) + } + + private func localListenAddress() -> String? { + guard let comps = URLComponents(string: serverURL), let host = comps.host else { return nil } + guard host == "127.0.0.1" || host == "localhost" else { return nil } + let port = comps.port ?? 3000 + return "\(host):\(port)" + } + + private func runShellCommand(_ command: String, currentDirectory: URL?) async -> ShellCommandResult { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-lc", command] + if let currentDirectory { + process.currentDirectoryURL = currentDirectory + } + + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = outputPipe + + do { + try process.run() + } catch { + continuation.resume( + returning: ShellCommandResult( + exitCode: -1, + output: "Failed to run command: \(error.localizedDescription)" + ) + ) + return + } + + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let output = String(data: data, encoding: .utf8) ?? "" + continuation.resume(returning: ShellCommandResult(exitCode: process.terminationStatus, output: output)) + } + } + } + + private func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private func appendLocalLog(_ text: String) { + let normalized = text.replacingOccurrences(of: "\r\n", with: "\n") + if !localSetupLog.isEmpty && !localSetupLog.hasSuffix("\n") { + localSetupLog.append("\n") + } + localSetupLog.append(normalized.trimmingCharacters(in: .newlines)) + if localSetupLog.count > 24_000 { + localSetupLog = String(localSetupLog.suffix(24_000)) + } + } + + private func parseCLIToken(from output: String) -> String? { + for line in output.split(separator: "\n") { + let rawLine = String(line) + guard rawLine.contains("auth token"), let range = rawLine.range(of: " is ") else { + continue + } + let token = rawLine[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) + if !token.isEmpty { + return token + } + } + return nil + } + + #endif + + private func previewToken(_ token: String) -> String { + if token.count > 16 { + return "\(token.prefix(8))...\(token.suffix(8))" + } + return token + } +} + +private struct SurfaceCard: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .padding(18) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(Color.white.opacity(0.24), lineWidth: 1) + ) + } +} + +private struct StatPill: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(title.uppercased()) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + Text(value) + .font(.headline.monospaced()) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + Capsule(style: .continuous) + .fill(.regularMaterial) + .overlay(Capsule(style: .continuous).stroke(Color.white.opacity(0.15), lineWidth: 1)) + ) + } +} + +private struct StepActionRow: View { + let step: Int + let title: String + let subtitle: String + let isComplete: Bool + let buttonTitle: String + let buttonRole: ButtonRole? + let buttonDisabled: Bool + let action: () -> Void + + init( + step: Int, + title: String, + subtitle: String, + isComplete: Bool, + buttonTitle: String, + buttonRole: ButtonRole? = nil, + buttonDisabled: Bool = false, + action: @escaping () -> Void + ) { + self.step = step + self.title = title + self.subtitle = subtitle + self.isComplete = isComplete + self.buttonTitle = buttonTitle + self.buttonRole = buttonRole + self.buttonDisabled = buttonDisabled + self.action = action + } + + var body: some View { + HStack(spacing: 10) { + Image(systemName: isComplete ? "checkmark.circle.fill" : "\(step).circle") + .foregroundStyle(isComplete ? Color.green : Color.secondary) + VStack(alignment: .leading, spacing: 2) { + Text("Step \(step): \(title)") + .font(.subheadline.weight(.semibold)) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button(buttonTitle, role: buttonRole, action: action) + .buttonStyle(.bordered) + .disabled(buttonDisabled) + } + } +} + +private struct PersonRow: View { + let person: Person + let onDelete: () -> Void + + private var timestampText: String { + let seconds = TimeInterval(person.createdAtMicros) / 1_000_000 + let date = Date(timeIntervalSince1970: seconds) + return date.formatted(date: .abbreviated, time: .standard) + } + + var body: some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color(red: 0.10, green: 0.59, blue: 0.96), Color(red: 0.18, green: 0.29, blue: 0.79)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 38, height: 38) + Text(String(person.name.prefix(1)).uppercased()) + .font(.headline.weight(.bold)) + .foregroundStyle(.white) + } + + VStack(alignment: .leading, spacing: 2) { + Text(person.name) + .font(.body.weight(.semibold)) + Text(timestampText) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text("#\(person.id)") + .font(.caption.monospaced()) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(Color.black.opacity(0.08))) + + Button(role: .destructive, action: onDelete) { + Image(systemName: "trash") + } + .buttonStyle(.bordered) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.white.opacity(0.55)) + ) + } +} + +struct ContentView: View { + @State private var vm = SimpleModuleViewModel() + @State private var showSetupLog = false + + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0.95, green: 0.96, blue: 0.99), + Color(red: 0.86, green: 0.90, blue: 0.97) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 14) { + header + controls + replica + } + .frame(maxWidth: 1120) + .frame(maxWidth: .infinity) + .padding(16) + } + } + .onDisappear { + vm.disconnect() + } + #if canImport(AppKit) + .onAppear { + NSApp.activate(ignoringOtherApps: true) + DispatchQueue.main.async { + NSApp.windows.first?.makeKeyAndOrderFront(nil) + } + } + #endif + } + + private var header: some View { + SurfaceCard { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Swift Simple Module Client") + .font(.title2.weight(.bold)) + Spacer() + Circle() + .fill(vm.isConnected ? Color.green : (vm.isConnecting ? Color.orange : Color.red)) + .frame(width: 10, height: 10) + } + + Text(vm.statusMessage) + .font(.subheadline) + .foregroundStyle(.secondary) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + StatPill(title: "Rows", value: "\(vm.people.count)") + StatPill(title: "Identity", value: vm.identityHex == "-" ? "-" : String(vm.identityHex.prefix(12))) + StatPill(title: "Token", value: vm.tokenPreview) + } + } + } + } + } + + private var controls: some View { + SurfaceCard { + VStack(spacing: 10) { + TextField("Server URL", text: $vm.serverURL) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + TextField("Database Name", text: $vm.databaseName) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + TextField("Auth Token (optional; useful for Maincloud)", text: $vm.connectToken) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + + HStack(spacing: 8) { + Button(vm.isConnected ? "Reconnect" : "Connect") { + vm.connect() + } + .buttonStyle(.borderedProminent) + + Button("Disconnect") { + vm.disconnect() + } + .buttonStyle(.bordered) + .disabled(!vm.isConnected && !vm.isConnecting) + + Spacer() + } + + HStack(spacing: 8) { + TextField("Name", text: $vm.draftName) + .textFieldStyle(.roundedBorder) + + Button("Add") { vm.addPerson() } + .buttonStyle(.borderedProminent) + .disabled(!vm.isConnected) + + Button("Add Sample") { vm.addSamplePerson() } + .buttonStyle(.bordered) + .disabled(!vm.isConnected) + + Button("Clear Replica") { vm.clearLocalReplica() } + .buttonStyle(.bordered) + } + + #if canImport(AppKit) + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Environment Presets") + .font(.subheadline.weight(.semibold)) + HStack(spacing: 8) { + Button("Use Local Preset") { vm.useLocalPreset() } + .buttonStyle(.bordered) + .disabled(vm.isLocalActionRunning) + Button("Use Maincloud Preset") { vm.useMaincloudPreset() } + .buttonStyle(.bordered) + .disabled(vm.isLocalActionRunning) + Spacer() + } + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Quick Start (Local macOS)") + .font(.subheadline.weight(.semibold)) + + HStack(spacing: 8) { + Circle() + .fill(vm.localServerReachable ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(vm.localNextStepText) + .font(.footnote) + .foregroundStyle(.secondary) + Spacer() + } + + StepActionRow( + step: 1, + title: "Start local server", + subtitle: "Launches `spacetime start` for localhost", + isComplete: vm.localServerReachable, + buttonTitle: "Start Local Server", + buttonDisabled: vm.isLocalActionRunning || !vm.isLocalServerTarget + ) { + vm.startLocalServer() + } + + StepActionRow( + step: 2, + title: "Publish module (\(vm.databaseName))", + subtitle: "Publishes this demo module to your local server", + isComplete: (vm.modulePublishedLocally && !vm.lastConnectBadResponse), + buttonTitle: "Publish Module", + buttonDisabled: vm.isLocalActionRunning || !vm.localServerReachable || !vm.isLocalServerTarget + ) { + vm.publishLocalModule() + } + + StepActionRow( + step: 3, + title: "Connect", + subtitle: "Connect the app to your local database", + isComplete: vm.isConnected && vm.isLocalServerTarget, + buttonTitle: vm.isConnected ? "Reconnect" : "Connect", + buttonDisabled: vm.isLocalActionRunning + ) { + vm.connect() + } + + HStack(spacing: 8) { + Button("Bootstrap Local (Recommended)") { vm.bootstrapLocalDemo() } + .buttonStyle(.borderedProminent) + .disabled(vm.isLocalActionRunning || !vm.isLocalServerTarget) + Button(showSetupLog ? "Hide Setup Log" : "Show Setup Log") { showSetupLog.toggle() } + .buttonStyle(.bordered) + .disabled(vm.localSetupLog.isEmpty) + Button("Clear Setup Log") { vm.clearLocalSetupLog() } + .buttonStyle(.bordered) + .disabled(vm.localSetupLog.isEmpty || vm.isLocalActionRunning) + Spacer() + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.white.opacity(0.30)) + ) + + VStack(alignment: .leading, spacing: 8) { + Text("Quick Test (spacetimedb.com / Maincloud)") + .font(.subheadline.weight(.semibold)) + + HStack(spacing: 8) { + Circle() + .fill(vm.isUsingMaincloud ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(vm.maincloudNextStepText) + .font(.footnote) + .foregroundStyle(.secondary) + Spacer() + } + + StepActionRow( + step: 1, + title: "Switch server URL to Maincloud", + subtitle: "Sets `https://maincloud.spacetimedb.com`", + isComplete: vm.isUsingMaincloud, + buttonTitle: "Use Maincloud Preset", + buttonDisabled: vm.isLocalActionRunning + ) { + vm.useMaincloudPreset() + } + + StepActionRow( + step: 2, + title: "Publish module (\(vm.databaseName)) to Maincloud", + subtitle: "Runs `spacetime publish -s maincloud ...`", + isComplete: (vm.modulePublishedOnMaincloud && !vm.lastConnectBadResponse), + buttonTitle: "Publish Maincloud Module", + buttonDisabled: vm.isLocalActionRunning || !vm.isUsingMaincloud + ) { + vm.publishMaincloudModule() + } + + StepActionRow( + step: 3, + title: "Connect to Maincloud", + subtitle: "Connect using optional auth token", + isComplete: vm.isConnected && vm.isUsingMaincloud, + buttonTitle: vm.isConnected ? "Reconnect" : "Connect", + buttonDisabled: vm.isLocalActionRunning || !vm.isUsingMaincloud + ) { + vm.connect() + } + + HStack(spacing: 8) { + Text("Optional: load token from `spacetime login show --token`") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Load CLI Token") { vm.loadCLIToken() } + .buttonStyle(.bordered) + .disabled(vm.isLocalActionRunning) + } + + if showSetupLog && !vm.localSetupLog.isEmpty { + ScrollView { + Text(vm.localSetupLog) + .font(.caption.monospaced()) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(8) + } + .frame(minHeight: 72, maxHeight: 120) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.black.opacity(0.05)) + ) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.white.opacity(0.30)) + ) + #endif + } + } + } + + private var replica: some View { + SurfaceCard { + VStack(alignment: .leading, spacing: 10) { + Text("People (Replicated Table: person)") + .font(.headline) + + if vm.people.isEmpty { + VStack(spacing: 8) { + Text("No rows yet") + .font(.headline) + Text("Connect, then add a person to see real-time replication.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, minHeight: 100) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.white.opacity(0.45)) + ) + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(vm.people, id: \.id) { person in + PersonRow(person: person) { + vm.deletePerson(id: person.id) + } + } + } + } + .frame(minHeight: 120) + } + } + } + } +} + +// MARK: - macOS lifecycle + +#if canImport(AppKit) +@MainActor +private final class SimpleModuleAppDelegate: NSObject, NSApplicationDelegate { + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } +} +#endif + +@main +struct SimpleModuleClientApp: App { + #if canImport(AppKit) + @NSApplicationDelegateAdaptor(SimpleModuleAppDelegate.self) private var appDelegate + #endif + + init() { + #if canImport(AppKit) + NSApplication.shared.setActivationPolicy(.regular) + #endif + } + + var body: some Scene { + WindowGroup { + ContentView() +#if os(macOS) + .frame(minWidth: 760, minHeight: 540) +#endif + } + } +} diff --git a/demo/simple-module/spacetimedb/Cargo.lock b/demo/simple-module/spacetimedb/Cargo.lock new file mode 100644 index 00000000000..cfa7388a6ce --- /dev/null +++ b/demo/simple-module/spacetimedb/Cargo.lock @@ -0,0 +1,960 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approx" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0e60b75072ecd4168020818c0107f2857bb6c4e64252d8d3983f6263b40a5c3" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "decorum" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "281759d3c8a14f5c3f0c49363be56810fcd7f910422f97f2db850c2920fde5cf" +dependencies = [ + "approx", + "num-traits", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "ethnum" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +dependencies = [ + "serde", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lean_string" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962df00ba70ac8d5ca5c064e17e5c3d090c087fd8d21aa45096c716b169da514" +dependencies = [ + "castaway", + "itoa", + "ryu", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "second-stack" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4904c83c6e51f1b9b08bfa5a86f35a51798e8307186e6f5513852210a219c0bb" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simple-module-demo" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spacetimedb" +version = "2.0.3" +dependencies = [ + "anyhow", + "bytemuck", + "bytes", + "derive_more", + "getrandom 0.2.17", + "http", + "log", + "rand 0.8.5", + "scoped-tls", + "serde_json", + "spacetimedb-bindings-macro", + "spacetimedb-bindings-sys", + "spacetimedb-lib", + "spacetimedb-primitives", + "spacetimedb-query-builder", +] + +[[package]] +name = "spacetimedb-bindings-macro" +version = "2.0.3" +dependencies = [ + "heck 0.4.1", + "humantime", + "proc-macro2", + "quote", + "spacetimedb-primitives", + "syn", +] + +[[package]] +name = "spacetimedb-bindings-sys" +version = "2.0.3" +dependencies = [ + "spacetimedb-primitives", +] + +[[package]] +name = "spacetimedb-lib" +version = "2.0.3" +dependencies = [ + "anyhow", + "bitflags", + "blake3", + "chrono", + "derive_more", + "enum-as-inner", + "hex", + "itertools", + "log", + "spacetimedb-bindings-macro", + "spacetimedb-primitives", + "spacetimedb-sats", + "thiserror", +] + +[[package]] +name = "spacetimedb-primitives" +version = "2.0.3" +dependencies = [ + "bitflags", + "either", + "enum-as-inner", + "itertools", + "nohash-hasher", +] + +[[package]] +name = "spacetimedb-query-builder" +version = "2.0.3" +dependencies = [ + "spacetimedb-lib", +] + +[[package]] +name = "spacetimedb-sats" +version = "2.0.3" +dependencies = [ + "anyhow", + "arrayvec", + "bitflags", + "bytemuck", + "bytes", + "chrono", + "decorum", + "derive_more", + "enum-as-inner", + "ethnum", + "hex", + "itertools", + "lean_string", + "rand 0.9.2", + "second-stack", + "sha3", + "smallvec", + "spacetimedb-bindings-macro", + "spacetimedb-primitives", + "thiserror", + "uuid", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/demo/simple-module/spacetimedb/Cargo.toml b/demo/simple-module/spacetimedb/Cargo.toml new file mode 100644 index 00000000000..915d5ce1552 --- /dev/null +++ b/demo/simple-module/spacetimedb/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "simple-module-demo" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb = { path = "../../../crates/bindings", version = "2.0.3" } +log = "0.4" + +[workspace] diff --git a/demo/simple-module/spacetimedb/src/lib.rs b/demo/simple-module/spacetimedb/src/lib.rs new file mode 100644 index 00000000000..67fd5413560 --- /dev/null +++ b/demo/simple-module/spacetimedb/src/lib.rs @@ -0,0 +1,39 @@ +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(accessor = person, public)] +pub struct Person { + #[primary_key] + #[auto_inc] + pub id: u64, + pub name: String, + pub created_at_micros: i64, + pub created_by_hex: String, +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext, name: String) { + let trimmed = name.trim(); + if trimmed.is_empty() { + log::warn!("Ignored add reducer with empty name"); + return; + } + + let created_by_hex = ctx.sender().to_hex().to_string(); + let created_at_micros = ctx.timestamp.to_micros_since_unix_epoch(); + + ctx.db.person().insert(Person { + id: 0, + name: trimmed.to_string(), + created_at_micros, + created_by_hex, + }); +} + +#[spacetimedb::reducer] +pub fn delete_person(ctx: &ReducerContext, id: u64) { + if ctx.db.person().id().find(id).is_some() { + ctx.db.person().id().delete(id); + } else { + log::warn!("delete_person ignored: id {id} not found"); + } +} diff --git a/demo/sync-ninja-game-from-mirror.sh b/demo/sync-ninja-game-from-mirror.sh new file mode 100755 index 00000000000..bb444d926d2 --- /dev/null +++ b/demo/sync-ninja-game-from-mirror.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Sync SpacetimeDB/demo/ninja-game from the standalone mirror repository. +# Usage: ./demo/sync-ninja-game-from-mirror.sh [repo-url] [branch] + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +TARGET_DIR="$REPO_ROOT/demo/ninja-game" +REPO_URL="${1:-https://github.com/avias8/spacetimedb-ninja-game.git}" +BRANCH="${2:-master}" + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +echo "Cloning $REPO_URL ($BRANCH)..." +git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$TMP_DIR/repo" + +echo "Syncing into $TARGET_DIR..." +rsync -a --delete \ + --exclude ".git" \ + --exclude ".DS_Store" \ + --exclude "client-swift/.build" \ + --exclude "spacetimedb/target" \ + "$TMP_DIR/repo/" "$TARGET_DIR/" + +echo "Done. Review with: git -C \"$REPO_ROOT\" status -- demo/ninja-game" diff --git a/sdks/swift/.gitignore b/sdks/swift/.gitignore new file mode 100644 index 00000000000..0023a534063 --- /dev/null +++ b/sdks/swift/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/sdks/swift/.spi.yml b/sdks/swift/.spi.yml new file mode 100644 index 00000000000..4f814a40e4a --- /dev/null +++ b/sdks/swift/.spi.yml @@ -0,0 +1,5 @@ +version: 1 +builder: + configs: + - documentation_targets: + - SpacetimeDB diff --git a/sdks/swift/Benchmarks/Baselines/README.md b/sdks/swift/Benchmarks/Baselines/README.md new file mode 100644 index 00000000000..744e7e8943d --- /dev/null +++ b/sdks/swift/Benchmarks/Baselines/README.md @@ -0,0 +1,53 @@ +# Swift Benchmark Baselines + +This directory defines the baseline capture format for `SpacetimeDBBenchmarks`. + +## Profile Naming + +Use a machine/profile baseline name so comparisons are meaningful across runs on the same hardware/toolchain. + +Recommended pattern: + +- `---swift` + +Example: + +- `macos-arm64-14.4-swift6.2` + +## Capture Command + +From repo root: + +```bash +tools/swift-benchmark-baseline.sh [benchmark-filter-regex] +``` + +Example: + +```bash +tools/swift-benchmark-baseline.sh macos-arm64-14.4-swift6.2 +``` + +Generated files: + +- `sdks/swift/.benchmarkBaselines/SpacetimeDBBenchmarks//results.json` + - raw `package-benchmark` baseline data (histograms + percentiles) +- `sdks/swift/Benchmarks/Baselines/captures//.baseline-results.json` + - copied snapshot of the raw baseline result +- `sdks/swift/Benchmarks/Baselines/captures//.summary.json` + - compact JSON summary (`jsonSmallerIsBetter` format) +- `sdks/swift/Benchmarks/Baselines/captures//.metadata.txt` + - machine/toolchain/profile details + exact benchmark commands + +`latest.*` aliases are also written in the same capture directory. + +## Comparison Method + +Compare two named baselines: + +```bash +cd sdks/swift +swift package benchmark baseline compare --target SpacetimeDBBenchmarks --no-progress +``` + +Use baseline names with matching machine profile for regression checks. diff --git a/sdks/swift/Benchmarks/GeneratedBindingsBenchmarks/GeneratedBindingsBenchmarks.swift b/sdks/swift/Benchmarks/GeneratedBindingsBenchmarks/GeneratedBindingsBenchmarks.swift new file mode 100644 index 00000000000..ad1129213b1 --- /dev/null +++ b/sdks/swift/Benchmarks/GeneratedBindingsBenchmarks/GeneratedBindingsBenchmarks.swift @@ -0,0 +1,216 @@ +import Benchmark +import Foundation +import SpacetimeDB + +private struct GeneratedPlayerCodableOnly: Codable, Sendable { + var id: UInt64 + var name: String + var x: Float + var y: Float + var health: UInt32 + var weaponCount: UInt32 + var kills: UInt32 + var respawnAtMicros: Int64 + var isReady: Bool + var lobbyId: UInt64? +} + +private struct GeneratedPlayerSpecial: Codable, Sendable, BSATNSpecialDecodable, BSATNSpecialEncodable { + var id: UInt64 + var name: String + var x: Float + var y: Float + var health: UInt32 + var weaponCount: UInt32 + var kills: UInt32 + var respawnAtMicros: Int64 + var isReady: Bool + var lobbyId: UInt64? + + static func decodeBSATN(from reader: inout BSATNReader) throws -> GeneratedPlayerSpecial { + GeneratedPlayerSpecial( + id: try reader.readU64(), + name: try reader.readString(), + x: try reader.readFloat(), + y: try reader.readFloat(), + health: try reader.readU32(), + weaponCount: try reader.readU32(), + kills: try reader.readU32(), + respawnAtMicros: try reader.readI64(), + isReady: try reader.readBool(), + lobbyId: try Optional.decodeBSATN(from: &reader) + ) + } + + func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.appendU64(id) + try storage.appendString(name) + storage.appendFloat(x) + storage.appendFloat(y) + storage.appendU32(health) + storage.appendU32(weaponCount) + storage.appendU32(kills) + storage.appendI64(respawnAtMicros) + storage.appendBool(isReady) + try lobbyId.encodeBSATN(to: &storage) + } +} + +private struct ReducerArgsCodableOnly: Codable, Sendable { + var targetId: UInt64 + var x: Float + var y: Float + var weaponSlot: UInt8 +} + +private struct ReducerArgsSpecial: Codable, Sendable, BSATNSpecialEncodable { + var targetId: UInt64 + var x: Float + var y: Float + var weaponSlot: UInt8 + + func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.appendU64(targetId) + storage.appendFloat(x) + storage.appendFloat(y) + storage.appendU8(weaponSlot) + } +} + +private let samplePlayer = GeneratedPlayerSpecial( + id: 42, + name: "PerfPlayer", + x: 123.45, + y: 678.9, + health: 99, + weaponCount: 2, + kills: 7, + respawnAtMicros: 1_700_000_000, + isReady: true, + lobbyId: 777 +) + +private let samplePlayerCodable = GeneratedPlayerCodableOnly( + id: samplePlayer.id, + name: samplePlayer.name, + x: samplePlayer.x, + y: samplePlayer.y, + health: samplePlayer.health, + weaponCount: samplePlayer.weaponCount, + kills: samplePlayer.kills, + respawnAtMicros: samplePlayer.respawnAtMicros, + isReady: samplePlayer.isReady, + lobbyId: samplePlayer.lobbyId +) + +private let encodedPlayerSpecial = try! BSATNEncoder().encode(samplePlayer) +private let encodedPlayerCodable = try! BSATNEncoder().encode(samplePlayerCodable) + +private let sampleReducerArgsCodable = ReducerArgsCodableOnly(targetId: 100, x: 1.0, y: 2.0, weaponSlot: 3) +private let sampleReducerArgsSpecial = ReducerArgsSpecial(targetId: 100, x: 1.0, y: 2.0, weaponSlot: 3) + +private let cacheRowsSpecial: [Data] = { + let encoder = BSATNEncoder() + return (0..<1000).map { i in + let row = GeneratedPlayerSpecial( + id: UInt64(i), + name: "P\(i)", + x: Float(i), + y: Float(i) * 2, + health: 100, + weaponCount: 1, + kills: 0, + respawnAtMicros: 0, + isReady: true, + lobbyId: UInt64(i % 8) + ) + return try! encoder.encode(row) + } +}() + +private let cacheRowsCodable: [Data] = { + let encoder = BSATNEncoder() + return cacheRowsSpecial.enumerated().map { i, _ in + let row = GeneratedPlayerCodableOnly( + id: UInt64(i), + name: "P\(i)", + x: Float(i), + y: Float(i) * 2, + health: 100, + weaponCount: 1, + kills: 0, + respawnAtMicros: 0, + isReady: true, + lobbyId: UInt64(i % 8) + ) + return try! encoder.encode(row) + } +}() + +let benchmarks: @Sendable () -> Void = { + Benchmark.defaultConfiguration = .init( + metrics: [.wallClock, .throughput], + maxDuration: .seconds(3), + maxIterations: 1_000_000 + ) + + Benchmark("Generated Encode Row (Codable)") { benchmark in + let encoder = BSATNEncoder() + for _ in benchmark.scaledIterations { + blackHole(try encoder.encode(samplePlayerCodable)) + } + } + + Benchmark("Generated Encode Row (BSATNSpecial)") { benchmark in + let encoder = BSATNEncoder() + for _ in benchmark.scaledIterations { + blackHole(try encoder.encode(samplePlayer)) + } + } + + Benchmark("Generated Decode Row (Codable)") { benchmark in + let decoder = BSATNDecoder() + for _ in benchmark.scaledIterations { + blackHole(try decoder.decode(GeneratedPlayerCodableOnly.self, from: encodedPlayerCodable)) + } + } + + Benchmark("Generated Decode Row (BSATNSpecial)") { benchmark in + let decoder = BSATNDecoder() + for _ in benchmark.scaledIterations { + blackHole(try decoder.decode(GeneratedPlayerSpecial.self, from: encodedPlayerSpecial)) + } + } + + Benchmark("Generated ReducerArgs Encode (Codable)") { benchmark in + let encoder = BSATNEncoder() + for _ in benchmark.scaledIterations { + blackHole(try encoder.encode(sampleReducerArgsCodable)) + } + } + + Benchmark("Generated ReducerArgs Encode (BSATNSpecial)") { benchmark in + let encoder = BSATNEncoder() + for _ in benchmark.scaledIterations { + blackHole(try encoder.encode(sampleReducerArgsSpecial)) + } + } + + Benchmark("Generated Cache Insert 1000 rows (Codable)") { benchmark in + for _ in benchmark.scaledIterations { + let cache = TableCache(tableName: "generated.players.codable") + for rowBytes in cacheRowsCodable { + try! cache.handleInsert(rowBytes: rowBytes) + } + } + } + + Benchmark("Generated Cache Insert 1000 rows (BSATNSpecial)") { benchmark in + for _ in benchmark.scaledIterations { + let cache = TableCache(tableName: "generated.players.special") + for rowBytes in cacheRowsSpecial { + try! cache.handleInsert(rowBytes: rowBytes) + } + } + } +} diff --git a/sdks/swift/Benchmarks/SpacetimeDBBenchmarks/SpacetimeDBBenchmarks.swift b/sdks/swift/Benchmarks/SpacetimeDBBenchmarks/SpacetimeDBBenchmarks.swift new file mode 100644 index 00000000000..a3eb47ab754 --- /dev/null +++ b/sdks/swift/Benchmarks/SpacetimeDBBenchmarks/SpacetimeDBBenchmarks.swift @@ -0,0 +1,413 @@ +import Benchmark +import Foundation +import SpacetimeDB + +// MARK: - Test Data Structures + +struct Point3D: Codable, Sendable, BSATNSpecialDecodable, BSATNSpecialEncodable { + var x: Float + var y: Float + var z: Float + + static func decodeBSATN(from reader: inout BSATNReader) throws -> Point3D { + return Point3D( + x: try reader.readFloat(), + y: try reader.readFloat(), + z: try reader.readFloat() + ) + } + + func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.appendFloat(x) + storage.appendFloat(y) + storage.appendFloat(z) + } +} + +struct PlayerRow: Codable, Sendable, BSATNSpecialDecodable, BSATNSpecialEncodable { + var id: UInt64 + var name: String + var x: Float + var y: Float + var health: UInt32 + var weaponCount: UInt32 + var kills: UInt32 + var respawnAtMicros: Int64 + var isReady: Bool + + static func decodeBSATN(from reader: inout BSATNReader) throws -> PlayerRow { + return PlayerRow( + id: try reader.readU64(), + name: try reader.readString(), + x: try reader.readFloat(), + y: try reader.readFloat(), + health: try reader.readU32(), + weaponCount: try reader.readU32(), + kills: try reader.readU32(), + respawnAtMicros: try reader.readI64(), + isReady: try reader.readBool() + ) + } + + func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.appendU64(id) + try storage.appendString(name) + storage.appendFloat(x) + storage.appendFloat(y) + storage.appendU32(health) + storage.appendU32(weaponCount) + storage.appendU32(kills) + storage.appendI64(respawnAtMicros) + storage.appendBool(isReady) + } +} + +struct GameState: Codable, Sendable, BSATNSpecialDecodable, BSATNSpecialEncodable { + var tick: UInt64 + var players: [PlayerRow] + var mapName: String + var timeLimit: UInt32 + + static func decodeBSATN(from reader: inout BSATNReader) throws -> GameState { + return GameState( + tick: try reader.readU64(), + players: try reader.readArray { reader in try PlayerRow.decodeBSATN(from: &reader) }, + mapName: try reader.readString(), + timeLimit: try reader.readU32() + ) + } + + func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.appendU64(tick) + storage.appendU32(UInt32(players.count)) + for player in players { + try player.encodeBSATN(to: &storage) + } + try storage.appendString(mapName) + storage.appendU32(timeLimit) + } +} + +// MARK: - Pre-built Test Data + +private let samplePoint = Point3D(x: 1.0, y: 2.0, z: 3.0) + +private let samplePlayer = PlayerRow( + id: 12345, name: "BenchmarkPlayer", x: 150.5, y: 200.3, + health: 100, weaponCount: 3, kills: 42, + respawnAtMicros: 1_700_000_000_000, isReady: true +) + +private let sampleGameState = GameState( + tick: 99999, + players: (0..<20).map { i in + PlayerRow( + id: UInt64(i), name: "Player \(i)", x: Float(i) * 10, y: Float(i) * 20, + health: 100, weaponCount: 1, kills: 0, + respawnAtMicros: 0, isReady: true + ) + }, + mapName: "benchmark_arena", + timeLimit: 300 +) + +private let encodedPoint: Data = try! BSATNEncoder().encode(samplePoint) +private let encodedPlayer: Data = try! BSATNEncoder().encode(samplePlayer) +private let encodedGameState: Data = try! BSATNEncoder().encode(sampleGameState) +private let reducerArgsPayload = Data(repeating: 0x2A, count: 128) +private let procedureArgsPayload = Data(repeating: 0x7F, count: 128) +private let reducerReturnPayload: Data = try! BSATNEncoder().encode(samplePoint) +private let procedureReturnPayload: Data = try! BSATNEncoder().encode(samplePlayer) + +private let encodedServerInitialConnection = makeInitialConnectionMessage() +private let encodedServerSubscriptionError = makeSubscriptionErrorMessage() +private let encodedServerTransactionUpdate = makeTransactionUpdateMessage() +private let encodedServerReducerResult = makeReducerResultMessage() +private let encodedServerProcedureResult = makeProcedureResultMessage() + +private func appendLE(_ value: T, to data: inout Data) { + var littleEndian = value.littleEndian + withUnsafeBytes(of: &littleEndian) { bytes in + data.append(contentsOf: bytes) + } +} + +private func appendBSATNString(_ value: String, to data: inout Data) { + data.append(try! BSATNEncoder().encode(value)) +} + +private func appendEmptyRowList(to data: inout Data) { + // RowSizeHint::RowOffsets([]) + appendLE(UInt8(1), to: &data) + appendLE(UInt32(0), to: &data) + appendLE(UInt32(0), to: &data) +} + +private func makeInitialConnectionMessage() -> Data { + var frame = Data([0]) // ServerMessage::InitialConnection + frame.append(Data(repeating: 0xAB, count: 32)) // identity + frame.append(Data(repeating: 0xCD, count: 16)) // connection_id + appendBSATNString("benchmark-token", to: &frame) + return frame +} + +private func makeSubscriptionErrorMessage() -> Data { + var frame = Data([3]) // ServerMessage::SubscriptionError + appendLE(UInt8(0), to: &frame) // request_id: Some + appendLE(UInt32(44), to: &frame) // request_id value + appendLE(UInt32(9), to: &frame) // query_set_id + appendBSATNString("bad query", to: &frame) + return frame +} + +private func makeTransactionUpdateMessage() -> Data { + var frame = Data([4]) // ServerMessage::TransactionUpdate + appendLE(UInt32(1), to: &frame) // query_sets count + + appendLE(UInt32(7), to: &frame) // query_set_id + appendLE(UInt32(1), to: &frame) // table updates count + + appendBSATNString("player", to: &frame) + appendLE(UInt32(1), to: &frame) // row updates count + appendLE(UInt8(0), to: &frame) // TableUpdateRows::PersistentTable + + // inserts + deletes row lists + appendEmptyRowList(to: &frame) + appendEmptyRowList(to: &frame) + return frame +} + +private func makeSubscribeMessage() -> ClientMessage { + .subscribe(Subscribe(queryStrings: ["SELECT * FROM player"], requestId: RequestId(rawValue: 1), querySetId: QuerySetId(rawValue: 7))) +} + +private func makeReducerMessage() -> ClientMessage { + .callReducer(CallReducer(requestId: RequestId(rawValue: 44), flags: 0, reducer: "move", args: reducerArgsPayload)) +} + +private func makeProcedureMessage() -> ClientMessage { + .callProcedure(CallProcedure(requestId: RequestId(rawValue: 45), flags: 0, procedure: "spawn", args: procedureArgsPayload)) +} + +private func makeReducerResultMessage() -> Data { + var frame = Data([6]) // ServerMessage::ReducerResult + appendLE(UInt32(44), to: &frame) // request_id + appendLE(Int64(1_700_000_000), to: &frame) // timestamp + appendLE(UInt8(0), to: &frame) // ReducerOutcome::Ok + appendLE(UInt32(reducerReturnPayload.count), to: &frame) // ret_value length + frame.append(reducerReturnPayload) // ret_value payload + appendLE(UInt32(0), to: &frame) // transaction_update.query_sets count + return frame +} + +private func makeProcedureResultMessage() -> Data { + var frame = Data([7]) // ServerMessage::ProcedureResult + appendLE(UInt8(0), to: &frame) // ProcedureStatus::Returned + appendLE(UInt32(procedureReturnPayload.count), to: &frame) // returned bytes length + frame.append(procedureReturnPayload) + appendLE(Int64(1_700_000_000), to: &frame) // timestamp + appendLE(Int64(2_000), to: &frame) // total_host_execution_duration + appendLE(UInt32(45), to: &frame) // request_id + return frame +} + +let benchmarks: @Sendable () -> Void = { + Benchmark.defaultConfiguration = .init( + metrics: [.wallClock, .throughput], + maxDuration: .seconds(3), + maxIterations: 1_000_000 + ) + + // MARK: - BSATN Encode + + Benchmark("BSATN Encode Point3D") { benchmark in + let encoder = BSATNEncoder() + for _ in benchmark.scaledIterations { + blackHole(try encoder.encode(samplePoint)) + } + } + + Benchmark("BSATN Encode PlayerRow") { benchmark in + let encoder = BSATNEncoder() + for _ in benchmark.scaledIterations { + blackHole(try encoder.encode(samplePlayer)) + } + } + + Benchmark("BSATN Encode GameState (20 players)") { benchmark in + let encoder = BSATNEncoder() + for _ in benchmark.scaledIterations { + blackHole(try encoder.encode(sampleGameState)) + } + } + + // MARK: - BSATN Decode + + Benchmark("BSATN Decode Point3D") { benchmark in + let decoder = BSATNDecoder() + for _ in benchmark.scaledIterations { + blackHole(try decoder.decode(Point3D.self, from: encodedPoint)) + } + } + + Benchmark("BSATN Decode PlayerRow") { benchmark in + let decoder = BSATNDecoder() + for _ in benchmark.scaledIterations { + blackHole(try decoder.decode(PlayerRow.self, from: encodedPlayer)) + } + } + + Benchmark("BSATN Decode GameState (20 players)") { benchmark in + let decoder = BSATNDecoder() + for _ in benchmark.scaledIterations { + blackHole(try decoder.decode(GameState.self, from: encodedGameState)) + } + } + + // MARK: - Message Encode/Decode + + Benchmark("Message Encode Subscribe") { benchmark in + let encoder = BSATNEncoder() + let message = makeSubscribeMessage() + for _ in benchmark.scaledIterations { + blackHole(try encoder.encode(message)) + } + } + + Benchmark("Message Encode CallReducer (128-byte args)") { benchmark in + let encoder = BSATNEncoder() + let message = makeReducerMessage() + for _ in benchmark.scaledIterations { + blackHole(try encoder.encode(message)) + } + } + + Benchmark("Message Encode CallProcedure (128-byte args)") { benchmark in + let encoder = BSATNEncoder() + let message = makeProcedureMessage() + for _ in benchmark.scaledIterations { + blackHole(try encoder.encode(message)) + } + } + + Benchmark("Message Decode InitialConnection") { benchmark in + let decoder = BSATNDecoder() + for _ in benchmark.scaledIterations { + blackHole(try decoder.decode(ServerMessage.self, from: encodedServerInitialConnection)) + } + } + + Benchmark("Message Decode SubscriptionError") { benchmark in + let decoder = BSATNDecoder() + for _ in benchmark.scaledIterations { + blackHole(try decoder.decode(ServerMessage.self, from: encodedServerSubscriptionError)) + } + } + + Benchmark("Message Decode TransactionUpdate (single table)") { benchmark in + let decoder = BSATNDecoder() + for _ in benchmark.scaledIterations { + blackHole(try decoder.decode(ServerMessage.self, from: encodedServerTransactionUpdate)) + } + } + + Benchmark("RoundTrip Reducer (encode request + decode result)") { benchmark in + let encoder = BSATNEncoder() + let decoder = BSATNDecoder() + let payloadDecoder = BSATNDecoder() + let request = makeReducerMessage() + for _ in benchmark.scaledIterations { + blackHole(try encoder.encode(request)) + let serverMessage = try decoder.decode(ServerMessage.self, from: encodedServerReducerResult) + guard case .reducerResult(let reducerResult) = serverMessage else { + fatalError("Expected reducer result") + } + guard case .ok(let ok) = reducerResult.result else { + fatalError("Expected reducer ok") + } + blackHole(try payloadDecoder.decode(Point3D.self, from: ok.retValue)) + } + } + + Benchmark("RoundTrip Procedure (encode request + decode result)") { benchmark in + let encoder = BSATNEncoder() + let decoder = BSATNDecoder() + let payloadDecoder = BSATNDecoder() + let request = makeProcedureMessage() + for _ in benchmark.scaledIterations { + blackHole(try encoder.encode(request)) + let serverMessage = try decoder.decode(ServerMessage.self, from: encodedServerProcedureResult) + guard case .procedureResult(let procedureResult) = serverMessage else { + fatalError("Expected procedure result") + } + guard case .returned(let returnedData) = procedureResult.status else { + fatalError("Expected procedure return payload") + } + blackHole(try payloadDecoder.decode(PlayerRow.self, from: returnedData)) + } + } + + // MARK: - Cache Operations + + Benchmark("Cache Insert 100 rows") { benchmark in + let encoder = BSATNEncoder() + let rows = (0..<100).map { i in + try! encoder.encode(Point3D(x: Float(i), y: Float(i), z: Float(i))) + } + for _ in benchmark.scaledIterations { + let cache = TableCache(tableName: "bench") + for rowBytes in rows { + try! cache.handleInsert(rowBytes: rowBytes) + } + // Performance: We only measure the background insertion/decoding here. + // In real usage, the UI will sync occasionally. + } + } + + Benchmark("Cache Insert 1000 rows") { benchmark in + let encoder = BSATNEncoder() + let rows = (0..<1000).map { i in + try! encoder.encode(Point3D(x: Float(i), y: Float(i), z: Float(i))) + } + for _ in benchmark.scaledIterations { + let cache = TableCache(tableName: "bench") + for rowBytes in rows { + try! cache.handleInsert(rowBytes: rowBytes) + } + } + } + + Benchmark("Cache Delete 500 rows from full") { benchmark in + let encoder = BSATNEncoder() + let rows = (0..<500).map { i in + try! encoder.encode(Point3D(x: Float(i), y: Float(i), z: Float(i))) + } + for _ in benchmark.scaledIterations { + let cache = TableCache(tableName: "bench") + for rowBytes in rows { + try! cache.handleInsert(rowBytes: rowBytes) + } + for rowBytes in rows { + try! cache.handleDelete(rowBytes: rowBytes) + } + } + } + + Benchmark("Cache Sync (1000 rows)") { benchmark in + let encoder = BSATNEncoder() + let rows = (0..<1000).map { i in + try! encoder.encode(Point3D(x: Float(i), y: Float(i), z: Float(i))) + } + let cache = TableCache(tableName: "bench") + for rowBytes in rows { + try! cache.handleInsert(rowBytes: rowBytes) + } + + for _ in benchmark.scaledIterations { + await MainActor.run { + cache.sync() + blackHole(cache.rows) + } + } + } +} diff --git a/sdks/swift/DISTRIBUTION.md b/sdks/swift/DISTRIBUTION.md new file mode 100644 index 00000000000..95fdb16ea92 --- /dev/null +++ b/sdks/swift/DISTRIBUTION.md @@ -0,0 +1,76 @@ +# Swift SDK Distribution Runbook + +This runbook defines how to ship `sdks/swift` as a standalone public Swift package for SPM and Swift Package Index. + +Execution checklist (submission + badge verification): + +- `sdks/swift/SPI_SUBMISSION_CHECKLIST.md` + +## Why Mirror Is Required + +The monorepo root is not a Swift package root. Public SPM consumers and Swift Package Index expect `Package.swift` at repository root. + +Use a dedicated mirror repository that contains the contents of `sdks/swift` at repository root (for example: `spacetimedb-swift`). + +## One-Time Setup + +1. Create the public mirror repository. +2. Clone it locally. +3. Add/keep `sdks/swift/.spi.yml` in mirror root. +4. Ensure `README.md` includes Swift Package Index links/badges once package is indexed. + +## Sync And Release Automation + +Automation script: + +- `tools/swift-package-mirror.sh` + +Sync only: + +```bash +tools/swift-package-mirror.sh sync --mirror ../spacetimedb-swift +``` + +Create release commit + tag: + +```bash +tools/swift-package-mirror.sh release --mirror ../spacetimedb-swift --version 0.1.0 +``` + +Create release and push: + +```bash +tools/swift-package-mirror.sh release --mirror ../spacetimedb-swift --version 0.1.0 --push +``` + +## Swift Package Index Submission + +1. Confirm mirror repository is public and contains package at root. +2. Push release tag (`vX.Y.Z`) in the mirror repository. +3. Submit package URL at: + - +4. Wait for indexing + documentation build to complete. + +## Swift Package Index Badge/Link Templates + +Replace `/` with the mirror repository coordinates. + +Package page: + +- `https://swiftpackageindex.com//` + +Swift versions badge: + +- `https://img.shields.io/endpoint?url=https://swiftpackageindex.com/api/packages///badge?type=swift-versions` + +Platforms badge: + +- `https://img.shields.io/endpoint?url=https://swiftpackageindex.com/api/packages///badge?type=platforms` + +## Verification Checklist + +- Mirror repo root contains `Package.swift`, `Sources`, `Tests`, `.spi.yml`. +- Mirror release tag pushed (`vX.Y.Z`). +- Package appears on Swift Package Index. +- Swift Package Index docs build succeeds. +- README in mirror repo contains working SPI package link and badges. diff --git a/sdks/swift/PERF_SPRINT_PLAN.md b/sdks/swift/PERF_SPRINT_PLAN.md new file mode 100644 index 00000000000..dad44060719 --- /dev/null +++ b/sdks/swift/PERF_SPRINT_PLAN.md @@ -0,0 +1,53 @@ +# Swift SDK Perf Sprint Plan + +## Goal + +Close the measured throughput gap in the Swift SDK hot paths (BSATN encode/decode, cache mutation, keynote TPS path) with repeatable evidence. + +## Baseline Discipline + +- Always benchmark release builds (`swift -c release`, `cargo --release`). +- Record exact command lines, host info, commit SHA, and server binary/version. +- Use the same contention/workload knobs when comparing SDKs. +- Run at least 3 samples and report min/avg/max. + +## Priority Work Items + +1. Encode fast paths (highest impact) +- Add bulk primitive array encode paths (`[UInt8]`, `[Int32]`, `[UInt32]`, `[Float]`, `[Int64]`, `[UInt64]`, `[Double]`) using contiguous writes. +- Hoist type checks outside per-element loops. +- Keep fallback generic/special-encodable paths for correctness. + +2. Decode fast paths +- Maintain zero-copy/low-copy read paths while preserving compatibility helpers (`read`, `readBytes`, `readArray`, `readTaggedEnum`). +- Add typed bulk decode branches where safe and measurable. +- Avoid unsafe array reinterprets that can violate type/layout guarantees. + +3. Primitive hook routing +- Ensure `encodeIfPresent`/`decodeIfPresent` and single-value containers use explicit primitive readers/writers. +- Avoid generic fallback for primitive Codable hooks. + +4. Cache hot path +- Profile `TableCache` insert/delete and reduce per-row overhead where possible. +- Add targeted cache microbench coverage for realistic batch sizes. + +5. Keynote TPS client path +- Reduce actor-hopping/synchronization overhead in hot send/ack loops. +- Keep workload knobs aligned with comparison methodology. + +## Validation Matrix + +- `swift test --package-path sdks/swift` +- `swift package --package-path sdks/swift benchmark --target SpacetimeDBBenchmarks --no-progress` +- Keynote TPS: 3-run sample (Swift + Rust clients), same server and knobs. + +## Reporting Template + +- Throughput table for: + - BSATN read/write + - Message encode/decode + - Cache insert/delete + - Round-trip reducer/procedure + - Keynote TPS +- Include deltas vs previous baseline and vs comparison SDK claims. +- Explicitly call out methodology differences when claims are not directly comparable. diff --git a/sdks/swift/PUBLISHING.md b/sdks/swift/PUBLISHING.md new file mode 100644 index 00000000000..431e7b83e14 --- /dev/null +++ b/sdks/swift/PUBLISHING.md @@ -0,0 +1,72 @@ +# Swift SDK Publishing Guide + +This guide prepares the Swift SDK for public Apple ecosystem consumption. + +## Scope + +- DocC documentation and tutorials +- Swift Package Index submission +- Apple platform CI confidence (macOS + iOS simulator; visionOS not targeted yet) +- mirror repository release process for public SPM consumption + +## Important Packaging Constraint + +The monorepo root is not a Swift package root. To publish as an SPM dependency and submit to Swift Package Index, use a dedicated package repository with the Swift SDK directory contents at repository root. + +Detailed distribution runbook: + +- `sdks/swift/DISTRIBUTION.md` + +Mirror/release automation: + +- `tools/swift-package-mirror.sh` + +Operational checklist for SPI submission + badge verification: + +- `sdks/swift/SPI_SUBMISSION_CHECKLIST.md` + +## DocC + +DocC bundle location: + +- `Sources/SpacetimeDB/SpacetimeDB.docc` + +Build docs locally: + +```bash +tools/swift-docc-smoke.sh +``` + +## Swift Package Index Submission + +1. Ensure public repository URL is accessible. +2. Push semantic version tag in the mirror repo (`vX.Y.Z`). +3. Submit URL at [https://swiftpackageindex.com/add-a-package](https://swiftpackageindex.com/add-a-package). +4. Verify docs generation and badge endpoints: + - Swift versions badge + - Supported platforms badge + +## CI Matrix + +The Swift SDK workflow should include: + +- macOS quality run: tests, lockfile validation, docs smoke, demos, benchmark smoke +- iOS simulator compile run for `SpacetimeDB` target +- explicit guard that visionOS is not targeted yet (`.visionOS(...)` absent in `Package.swift`) + +Current workflow file: + +- `.github/workflows/swift-sdk.yml` + +## Release Checklist + +- `swift test --package-path sdks/swift` +- `swift package --package-path sdks/swift resolve --force-resolved-versions` +- `tools/swift-benchmark-smoke.sh` +- `tools/swift-benchmark-baseline.sh ` +- `tools/swift-docc-smoke.sh` +- CI matrix green on PR and default branch +- `tools/swift-package-mirror.sh sync --mirror ` +- `tools/swift-package-mirror.sh release --mirror --version --push` +- Submit mirror repo URL to Swift Package Index +- Verify SPI package page + docs + badge URLs diff --git a/sdks/swift/Package.resolved b/sdks/swift/Package.resolved new file mode 100644 index 00000000000..6e8602a9b12 --- /dev/null +++ b/sdks/swift/Package.resolved @@ -0,0 +1,69 @@ +{ + "originHash" : "55ca06d1704be6390cc8eb79affbb4326d0891a1ded418bc2f7b37deb8de5e9d", + "pins" : [ + { + "identity" : "hdrhistogram-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/HdrHistogram/hdrhistogram-swift.git", + "state" : { + "revision" : "de0b9b8a27956b9bfc9b4dce7d1c38ad7c579f19", + "version" : "0.1.4" + } + }, + { + "identity" : "package-benchmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ordo-one/package-benchmark", + "state" : { + "revision" : "1ce127f3bdd2803b0b8f5ac72c940e5a29ca65a9", + "version" : "1.30.0" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "011f0c765fb46d9cac61bca19be0527e99c98c8b", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "texttable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ordo-one/TextTable.git", + "state" : { + "revision" : "a27a07300cf4ae322e0079ca0a475c5583dd575f", + "version" : "0.0.2" + } + } + ], + "version" : 3 +} diff --git a/sdks/swift/Package.swift b/sdks/swift/Package.swift new file mode 100644 index 00000000000..c9caa12c428 --- /dev/null +++ b/sdks/swift/Package.swift @@ -0,0 +1,57 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "SpacetimeDB", + platforms: [ + .macOS(.v15), + .iOS(.v18), + .visionOS(.v2), + .watchOS(.v11) + ], + products: [ + .library( + name: "SpacetimeDB", + targets: ["SpacetimeDB"] + ), + ], + dependencies: [ + .package( + url: "https://github.com/ordo-one/package-benchmark", + from: "1.30.0", + traits: [] + ), + ], + targets: [ + .target( + name: "SpacetimeDB" + ), + .testTarget( + name: "SpacetimeDBTests", + dependencies: ["SpacetimeDB"] + ), + .executableTarget( + name: "SpacetimeDBBenchmarks", + dependencies: [ + "SpacetimeDB", + .product(name: "Benchmark", package: "package-benchmark"), + ], + path: "Benchmarks/SpacetimeDBBenchmarks", + plugins: [ + .plugin(name: "BenchmarkPlugin", package: "package-benchmark"), + ] + ), + .executableTarget( + name: "GeneratedBindingsBenchmarks", + dependencies: [ + "SpacetimeDB", + .product(name: "Benchmark", package: "package-benchmark"), + ], + path: "Benchmarks/GeneratedBindingsBenchmarks", + plugins: [ + .plugin(name: "BenchmarkPlugin", package: "package-benchmark"), + ] + ), + ] +) diff --git a/sdks/swift/README.md b/sdks/swift/README.md new file mode 100644 index 00000000000..dfa36de95fc --- /dev/null +++ b/sdks/swift/README.md @@ -0,0 +1,362 @@ +# SpacetimeDB Swift SDK + +Native Swift SDK for connecting to SpacetimeDB over `v2.bsatn.spacetimedb`, decoding realtime updates, and maintaining a typed local cache. + +## Contents + +- [Requirements](#requirements) +- [Package Layout](#package-layout) +- [Add The SDK To A Swift Package](#add-the-sdk-to-a-swift-package) +- [Quick Start](#quick-start) +- [Core API](#core-api) +- [Auth Token Persistence (Keychain)](#auth-token-persistence-keychain) +- [Network Awareness And Reconnect Behavior](#network-awareness-and-reconnect-behavior) +- [Distribution Hardening](#distribution-hardening) +- [DocC and Swift Package Index](#docc-and-swift-package-index) +- [Apple CI Matrix](#apple-ci-matrix) +- [Logging](#logging) +- [Benchmarks](#benchmarks) +- [Validation Matrix](#validation-matrix) +- [Examples](#examples) + +## Requirements + +- Swift tools `6.2` +- Apple platforms: + - macOS `15+` + - iOS `17+` + - visionOS: not supported yet + +## Package Layout + +```text +sdks/swift +├── Package.swift +├── Benchmarks/SpacetimeDBBenchmarks +├── Sources/SpacetimeDB +│ ├── Auth/KeychainTokenStore.swift +│ ├── BSATN/ +│ ├── Cache/ +│ ├── Network/ +│ ├── Log.swift +│ ├── RuntimeTypes.swift +│ └── SpacetimeDB.swift +└── Tests/SpacetimeDBTests +``` + +## Add The SDK To A Swift Package + +From a local checkout: + +```swift +dependencies: [ + .package(name: "SpacetimeDB", path: "../../../sdks/swift"), +], +targets: [ + .executableTarget( + name: "MyClient", + dependencies: [ + .product(name: "SpacetimeDB", package: "SpacetimeDB"), + ] + ), +] +``` + +Then import in code: + +```swift +import SpacetimeDB +``` + +## Quick Start + +```swift +import Foundation +import SpacetimeDB + +@MainActor +final class AppModel: SpacetimeClientDelegate { + private var client: SpacetimeClient? + + func start() { + SpacetimeModule.registerTables() + + let client = SpacetimeClient( + serverUrl: URL(string: "http://127.0.0.1:3000")!, + moduleName: "my-module" + ) + client.delegate = self + client.connect() + self.client = client + } + + func stop() { + client?.disconnect() + client = nil + } + + // MARK: SpacetimeClientDelegate + func onConnect() {} + func onDisconnect(error: Error?) {} + func onIdentityReceived(identity: [UInt8], token: String) {} + func onTransactionUpdate(message: Data?) {} + func onReducerError(reducer: String, message: String, isInternal: Bool) {} +} +``` + +## Core API + +### Connect/Disconnect + +```swift +let client = SpacetimeClient(serverUrl: url, moduleName: "my-module") +client.delegate = delegate +client.connect(token: optionalBearerToken) +client.disconnect() +``` + +### Reducers + +```swift +client.send("add_person", argsData) +``` + +### Procedures + +Typed callback: + +```swift +client.sendProcedure("hello", argsData, responseType: String.self) { result in + // Result +} +``` + +Raw callback: + +```swift +client.sendProcedure("hello", argsData) { result in + // Result +} +``` + +`async/await`: + +```swift +let rawData = try await client.sendProcedure("hello", argsData) +let value = try await client.sendProcedure("hello", argsData, responseType: String.self) +let timed = try await client.sendProcedure("hello", argsData, timeout: .seconds(5)) +let typedTimed = try await client.sendProcedure("hello", argsData, responseType: String.self, timeout: .seconds(5)) +``` + +### One-Off Queries + +Callback: + +```swift +client.oneOffQuery("SELECT * FROM person") { result in + // Result +} +``` + +`async/await`: + +```swift +let rows = try await client.oneOffQuery("SELECT * FROM person") +let timedRows = try await client.oneOffQuery("SELECT * FROM person", timeout: .seconds(3)) +``` + +Cancellation: cancel the task calling an async procedure/query API, and it throws `CancellationError` while removing the pending callback state. + +### Subscriptions + +```swift +let handle = client.subscribe( + queries: ["SELECT * FROM person"], + onApplied: { /* initial snapshot applied */ }, + onError: { message in /* subscription failed */ } +) + +handle.unsubscribe() +``` + +### Client Cache + +Register tables once, then read typed rows from generated caches: + +```swift +SpacetimeModule.registerTables() +let people = PersonTable.cache.rows +``` + +The SDK applies transaction updates into `SpacetimeClient.clientCache` and table caches are updated on the main actor. + +## Auth Token Persistence (Keychain) + +`KeychainTokenStore` provides opt-in token persistence per module: + +```swift +let tokenStore = KeychainTokenStore(service: "com.example.myapp.spacetimedb") + +// Load on app start. +let savedToken = tokenStore.load(forModule: "my-module") +client.connect(token: savedToken) + +// Save when identity/token arrives. +func onIdentityReceived(identity: [UInt8], token: String) { + tokenStore.save(token: token, forModule: "my-module") +} +``` + +## Network Awareness And Reconnect Behavior + +`SpacetimeClient` supports reconnect backoff through `ReconnectPolicy`: + +```swift +let policy = ReconnectPolicy( + maxRetries: nil, + initialDelaySeconds: 1.0, + maxDelaySeconds: 30.0, + multiplier: 2.0, + jitterRatio: 0.2 +) +``` + +Network path changes are monitored internally. When the device is offline, reconnect attempts are deferred; when connectivity returns, reconnect is retriggered automatically. + +Compression mode can be set at client construction: + +```swift +let client = SpacetimeClient( + serverUrl: url, + moduleName: "my-module", + reconnectPolicy: policy, + compressionMode: .gzip // .none | .gzip | .brotli +) +``` + +## Distribution Hardening + +The Swift package is validated in CI for reproducibility and packaging health: + +- `swift test --package-path sdks/swift` +- `swift package --package-path sdks/swift resolve --force-resolved-versions` +- demo package builds +- benchmark smoke run + +Dependency versions are pinned in `sdks/swift/Package.resolved` to avoid accidental drift. + +For public SPM/SPI distribution from this monorepo, use the mirror runbook and automation: + +- `sdks/swift/DISTRIBUTION.md` +- `tools/swift-package-mirror.sh` + +## DocC and Swift Package Index + +DocC bundle and tutorials live in: + +- `sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc` + +DocC build command: + +```bash +tools/swift-docc-smoke.sh +``` + +Swift Package Index builder config is in: + +- `sdks/swift/.spi.yml` + +Detailed publishing runbook: + +- `sdks/swift/PUBLISHING.md` +- `sdks/swift/DISTRIBUTION.md` +- `sdks/swift/SPI_SUBMISSION_CHECKLIST.md` + +Swift Package Index link and badge templates (replace `/` with mirror coordinates): + +```text +Package: https://swiftpackageindex.com// +Swift versions badge: https://img.shields.io/endpoint?url=https://swiftpackageindex.com/api/packages///badge?type=swift-versions +Platforms badge: https://img.shields.io/endpoint?url=https://swiftpackageindex.com/api/packages///badge?type=platforms +``` + +## Apple CI Matrix + +Swift CI runs as a platform matrix in `.github/workflows/swift-sdk.yml`: + +- `macOS`: tests, lockfile validation, demos, benchmark smoke, DocC build +- `iOS simulator`: cross-build of `SpacetimeDB` target +- `visionOS`: intentionally not targeted yet; CI asserts `.visionOS(...)` is absent in `Package.swift` + +## Logging + +The SDK uses `os.Logger` categories: + +- `Client` +- `Cache` +- `Network` + +Logs are visible in Console.app and device logs using subsystem `com.clockworklabs.SpacetimeDB`. + +## Benchmarks + +Benchmark target: `SpacetimeDBBenchmarks` + +Includes suites for: + +- BSATN encode/decode +- protocol message encode/decode +- reducer/procedure request+response round-trip encode/decode +- cache insert/delete throughput + +Run from repo root: + +```bash +swift package --package-path sdks/swift benchmark --target SpacetimeDBBenchmarks +``` + +List available benchmarks: + +```bash +swift package --package-path sdks/swift benchmark list +``` + +Run fast smoke benchmarks (used by CI): + +```bash +tools/swift-benchmark-smoke.sh +``` + +Capture a named reproducible baseline (raw + summary + machine metadata): + +```bash +tools/swift-benchmark-baseline.sh macos-arm64-14.4-swift6.2 +``` + +Compare two captured baselines: + +```bash +cd sdks/swift +swift package benchmark baseline compare --target SpacetimeDBBenchmarks --no-progress +``` + +## Validation Matrix + +From repo root: + +```bash +swift test --package-path sdks/swift +swift build --package-path sdks/swift +swift build --package-path demo/simple-module/client-swift +swift build --package-path demo/ninja-game/client-swift +swift package --package-path sdks/swift resolve --force-resolved-versions +swift package --package-path sdks/swift benchmark list +swift package --package-path sdks/swift benchmark --target SpacetimeDBBenchmarks +tools/swift-benchmark-smoke.sh +tools/swift-docc-smoke.sh +``` + +## Examples + +- `demo/simple-module/client-swift` +- `demo/ninja-game/client-swift` diff --git a/sdks/swift/SPI_SUBMISSION_CHECKLIST.md b/sdks/swift/SPI_SUBMISSION_CHECKLIST.md new file mode 100644 index 00000000000..c86263a20ab --- /dev/null +++ b/sdks/swift/SPI_SUBMISSION_CHECKLIST.md @@ -0,0 +1,125 @@ +# Swift Package Index Submission + Badge Verification Checklist + +Use this checklist when releasing the standalone mirror repository for `sdks/swift` (for example: `spacetimedb-swift`). + +## Release Inputs + +- [ ] Set release variables: + +```bash +export MIRROR_REPO="../spacetimedb-swift" +export SPI_OWNER="" +export SPI_REPO="" +export SPI_VERSION="0.1.0" +``` + +## 1. One-Time Mirror Readiness + +- [ ] Mirror repository is public. +- [ ] Mirror repository root contains package files: + - `Package.swift` + - `Package.resolved` + - `Sources/` + - `Tests/` + - `.spi.yml` +- [ ] `.spi.yml` includes `SpacetimeDB` as a documentation target. + +Quick check: + +```bash +ls -la "$MIRROR_REPO" +cat "$MIRROR_REPO/.spi.yml" +``` + +## 2. Monorepo Preflight + +Run from monorepo root before mirroring: + +- [ ] `swift test --package-path sdks/swift` +- [ ] `swift package --package-path sdks/swift resolve --force-resolved-versions` +- [ ] `tools/swift-benchmark-smoke.sh` +- [ ] `tools/swift-docc-smoke.sh` +- [ ] `tools/check-swift-demo-bindings.sh` +- [ ] `swift build --package-path demo/simple-module/client-swift` +- [ ] `swift build --package-path demo/ninja-game/client-swift` + +## 3. Mirror Sync + Release Tag + +- [ ] Dry-run mirror sync: + +```bash +tools/swift-package-mirror.sh sync --mirror "$MIRROR_REPO" --dry-run +``` + +- [ ] Create release commit + tag + push: + +```bash +tools/swift-package-mirror.sh release --mirror "$MIRROR_REPO" --version "$SPI_VERSION" --push +``` + +- [ ] Confirm the release tag exists locally and on origin: + +```bash +git -C "$MIRROR_REPO" tag --list "v$SPI_VERSION" +git -C "$MIRROR_REPO" ls-remote --tags origin "refs/tags/v$SPI_VERSION" +``` + +## 4. Submit Package To SPI (Manual) + +- [ ] Open . +- [ ] Submit mirror repository URL (`https://github.com/$SPI_OWNER/$SPI_REPO`). +- [ ] Confirm submission acceptance and monitor indexing/doc generation progress. + +## 5. Verify SPI Badge APIs + +Use SPI badge JSON endpoints to confirm indexing has completed: + +```bash +SPI_API_BASE="https://swiftpackageindex.com/api/packages/$SPI_OWNER/$SPI_REPO" + +curl -fsSL "$SPI_API_BASE/badge?type=swift-versions" -o /tmp/spi-swift-versions.json +curl -fsSL "$SPI_API_BASE/badge?type=platforms" -o /tmp/spi-platforms.json + +grep -q '"isError":false' /tmp/spi-swift-versions.json +grep -q '"isError":false' /tmp/spi-platforms.json + +grep -o '"message":"[^"]*"' /tmp/spi-swift-versions.json +grep -o '"message":"[^"]*"' /tmp/spi-platforms.json +``` + +- [ ] Both JSON payloads report `"isError":false`. +- [ ] Swift versions message is populated (not `pending`). +- [ ] Platforms message is populated (not `pending`). + +## 6. Verify Shield Badge Endpoints + +```bash +SPI_API_BASE="https://swiftpackageindex.com/api/packages/$SPI_OWNER/$SPI_REPO" +SWIFT_BADGE="https://img.shields.io/endpoint?url=$SPI_API_BASE/badge?type=swift-versions" +PLATFORMS_BADGE="https://img.shields.io/endpoint?url=$SPI_API_BASE/badge?type=platforms" + +curl -fsSL "$SWIFT_BADGE" | head -n 1 +curl -fsSL "$PLATFORMS_BADGE" | head -n 1 +``` + +- [ ] Both responses are SVG payloads. + +## 7. Mirror README Links + Badges + +- [ ] Mirror README includes working package page link and badge markdown: + +```markdown +[Swift Package Index](https://swiftpackageindex.com//) + +![Swift Versions](https://img.shields.io/endpoint?url=https://swiftpackageindex.com/api/packages///badge?type=swift-versions) +![Platforms](https://img.shields.io/endpoint?url=https://swiftpackageindex.com/api/packages///badge?type=platforms) +``` + +- [ ] Replace all `/` placeholders with mirror coordinates. + +## 8. Final Sign-Off + +- [ ] SPI package page loads in browser: + - `https://swiftpackageindex.com/$SPI_OWNER/$SPI_REPO` +- [ ] SPI documentation page for `SpacetimeDB` target is available from package page. +- [ ] Release notes/backlog reflect submission completion. diff --git a/sdks/swift/Sources/SpacetimeDB/Auth/KeychainTokenStore.swift b/sdks/swift/Sources/SpacetimeDB/Auth/KeychainTokenStore.swift new file mode 100644 index 00000000000..9819cc95d25 --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/Auth/KeychainTokenStore.swift @@ -0,0 +1,88 @@ +import Foundation +import Security + +/// Opt-in persistent token storage using the system Keychain. +/// +/// Use this to persist SpacetimeDB auth tokens across app launches. +/// Tokens are stored per-module under a service identifier you control. +/// +/// Example usage: +/// ```swift +/// let store = KeychainTokenStore(service: "com.myapp.spacetimedb") +/// +/// // On launch, load a saved token: +/// let token = store.load(forModule: "my-module") +/// client.connect(token: token) +/// +/// // After receiving identity, save the token: +/// func onIdentityReceived(identity: [UInt8], token: String) { +/// store.save(token: token, forModule: "my-module") +/// } +/// ``` +public struct KeychainTokenStore: Sendable { + private let service: String + + /// Creates a Keychain token store. + /// - Parameter service: The Keychain service identifier, typically your app's bundle ID. + public init(service: String) { + self.service = service + } + + /// Saves a token to the Keychain for the given module name. + @discardableResult + public func save(token: String, forModule module: String) -> Bool { + let data = Data(token.utf8) + + // Delete existing item first to avoid errSecDuplicateItem + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: module, + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: module, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + if status != errSecSuccess { + Log.client.warning("Keychain save failed with status \(status)") + } + return status == errSecSuccess + } + + /// Loads a previously stored token from the Keychain. + public func load(forModule module: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: module, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + } + + /// Deletes a stored token from the Keychain. + @discardableResult + public func delete(forModule module: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: module, + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } +} diff --git a/sdks/swift/Sources/SpacetimeDB/BSATN/BSATNDecoder.swift b/sdks/swift/Sources/SpacetimeDB/BSATN/BSATNDecoder.swift new file mode 100644 index 00000000000..1a8d7e2b7ce --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/BSATN/BSATNDecoder.swift @@ -0,0 +1,576 @@ +import Foundation + +@_documentation(visibility: internal) +public enum BSATNDecodingError: Error, Equatable, BitwiseCopyable { + case unexpectedEndOfData + case invalidStringEncoding + case invalidType + case unsupportedType +} + +@_documentation(visibility: internal) +public struct BSATNReader: ~Copyable { + public let buffer: UnsafeRawBufferPointer + public var offset: Int = 0 + + public init(buffer: UnsafeRawBufferPointer, offset: Int = 0) { + self.buffer = buffer + self.offset = offset + } + + public var isAtEnd: Bool { + return offset >= buffer.count + } + + public var remaining: Int { + return max(0, buffer.count - offset) + } + + public mutating func readBytes(count: Int) throws(BSATNDecodingError) -> Data { + guard offset + count <= buffer.count else { + throw .unexpectedEndOfData + } + let bytes = Data(buffer[offset..<(offset + count)]) + offset += count + return bytes + } + + public mutating func read(_ type: T.Type) throws(BSATNDecodingError) -> T { + let size = MemoryLayout.size + guard offset + size <= buffer.count else { + throw .unexpectedEndOfData + } + let value = buffer.loadUnaligned(fromByteOffset: offset, as: T.self) + offset += size + return T(littleEndian: value) + } + + public mutating func readU8() throws(BSATNDecodingError) -> UInt8 { + guard offset + 1 <= buffer.count else { throw .unexpectedEndOfData } + let val = buffer[offset] + offset += 1 + return val + } + + public mutating func readU16() throws(BSATNDecodingError) -> UInt16 { + guard offset + 2 <= buffer.count else { throw .unexpectedEndOfData } + let val = buffer.loadUnaligned(fromByteOffset: offset, as: UInt16.self) + offset += 2 + return UInt16(littleEndian: val) + } + + public mutating func readU32() throws(BSATNDecodingError) -> UInt32 { + guard offset + 4 <= buffer.count else { throw .unexpectedEndOfData } + let val = buffer.loadUnaligned(fromByteOffset: offset, as: UInt32.self) + offset += 4 + return UInt32(littleEndian: val) + } + + public mutating func readU64() throws(BSATNDecodingError) -> UInt64 { + guard offset + 8 <= buffer.count else { throw .unexpectedEndOfData } + let val = buffer.loadUnaligned(fromByteOffset: offset, as: UInt64.self) + offset += 8 + return UInt64(littleEndian: val) + } + + public mutating func readI8() throws(BSATNDecodingError) -> Int8 { + guard offset + 1 <= buffer.count else { throw .unexpectedEndOfData } + let val = Int8(bitPattern: buffer[offset]) + offset += 1 + return val + } + + public mutating func readI16() throws(BSATNDecodingError) -> Int16 { + guard offset + 2 <= buffer.count else { throw .unexpectedEndOfData } + let val = buffer.loadUnaligned(fromByteOffset: offset, as: Int16.self) + offset += 2 + return Int16(littleEndian: val) + } + + public mutating func readI32() throws(BSATNDecodingError) -> Int32 { + guard offset + 4 <= buffer.count else { throw .unexpectedEndOfData } + let val = buffer.loadUnaligned(fromByteOffset: offset, as: Int32.self) + offset += 4 + return Int32(littleEndian: val) + } + + public mutating func readI64() throws(BSATNDecodingError) -> Int64 { + guard offset + 8 <= buffer.count else { throw .unexpectedEndOfData } + let val = buffer.loadUnaligned(fromByteOffset: offset, as: Int64.self) + offset += 8 + return Int64(littleEndian: val) + } + + public mutating func readDouble() throws(BSATNDecodingError) -> Double { + guard offset + 8 <= buffer.count else { + throw .unexpectedEndOfData + } + let bits = buffer.loadUnaligned(fromByteOffset: offset, as: UInt64.self) + offset += 8 + return Double(bitPattern: UInt64(littleEndian: bits)) + } + + public mutating func readFloat() throws(BSATNDecodingError) -> Float { + guard offset + 4 <= buffer.count else { + throw .unexpectedEndOfData + } + let bits = buffer.loadUnaligned(fromByteOffset: offset, as: UInt32.self) + offset += 4 + return Float(bitPattern: UInt32(littleEndian: bits)) + } + + public mutating func readString() throws -> String { + let length = Int(try readU32()) + guard offset + length <= buffer.count else { + throw BSATNDecodingError.unexpectedEndOfData + } + let stringStart = buffer.baseAddress!.advanced(by: offset).assumingMemoryBound(to: UInt8.self) + let string = String(decoding: UnsafeBufferPointer(start: stringStart, count: length), as: UTF8.self) + offset += length + return string + } + + public mutating func readBool() throws(BSATNDecodingError) -> Bool { + let byte = try read(UInt8.self) + switch byte { + case 0: return false + case 1: return true + default: throw .invalidType + } + } + + public mutating func readArray(_ block: (inout BSATNReader) throws -> T) throws -> [T] { + let count = try readU32() + var elements: [T] = [] + elements.reserveCapacity(Int(count)) + for _ in 0..(_ block: (inout BSATNReader, UInt8) throws -> T) throws -> T { + let tag = try readU8() + return try block(&self, tag) + } + + public mutating func readPrimitiveArray(_ type: T.Type, count: Int) throws(BSATNDecodingError) -> [T] { + let stride = MemoryLayout.stride + let byteCount = count * stride + guard offset + byteCount <= buffer.count else { + throw .unexpectedEndOfData + } + if count == 0 { return [] } + + let src = buffer.baseAddress!.advanced(by: offset) + let values: [T] = Array(unsafeUninitializedCapacity: count) { bufferOut, initializedCount in + memcpy(bufferOut.baseAddress!, src, byteCount) + initializedCount = count + } + offset += byteCount + +#if _endian(little) + return values +#else + var converted = values + for i in converted.indices { + converted[i] = T(littleEndian: converted[i]) + } + return converted +#endif + } + + public mutating func readFloatArray(count: Int) throws(BSATNDecodingError) -> [Float] { + let bits = try readPrimitiveArray(UInt32.self, count: count) + var values = Array() + values.reserveCapacity(bits.count) + for bitPattern in bits { + values.append(Float(bitPattern: bitPattern)) + } + return values + } + + + public mutating func readDoubleArray(count: Int) throws(BSATNDecodingError) -> [Double] { + let bits = try readPrimitiveArray(UInt64.self, count: count) + var values = Array() + values.reserveCapacity(bits.count) + for bitPattern in bits { + values.append(Double(bitPattern: bitPattern)) + } + return values + } + + public mutating func fallbackDecode(_ type: T.Type) throws -> T { + let wrapper = BSATNReaderWrapper(reader: BSATNReader(buffer: buffer, offset: offset)) + let decoder = _BSATNDecoder(storage: wrapper, codingPath: []) + let result = try T(from: decoder) + self.offset = wrapper.reader.offset + return result + } +} + +@_documentation(visibility: internal) +public protocol BSATNFastCopyable: BitwiseCopyable {} +extension UInt8: BSATNFastCopyable {} +extension Int8: BSATNFastCopyable {} +extension UInt16: BSATNFastCopyable {} +extension Int16: BSATNFastCopyable {} +extension UInt32: BSATNFastCopyable {} +extension Int32: BSATNFastCopyable {} +extension UInt64: BSATNFastCopyable {} +extension Int64: BSATNFastCopyable {} +extension Float: BSATNFastCopyable {} +extension Double: BSATNFastCopyable {} + +class BSATNReaderWrapper { + var reader: BSATNReader + init(reader: consuming BSATNReader) { + self.reader = reader + } + + func withReader(_ block: (inout BSATNReader) throws -> T) rethrows -> T { + try block(&reader) + } +} + +@_documentation(visibility: internal) +public final class BSATNDecoder: Sendable { + public init() {} + + public func decode(_ type: T.Type, from data: Data) throws -> T { + return try data.withUnsafeBytes { buffer in + var reader = BSATNReader(buffer: buffer) + if let specialType = type as? BSATNSpecialDecodable.Type { + return try specialType.decodeBSATN(from: &reader) as! T + } + let wrapper = BSATNReaderWrapper(reader: BSATNReader(buffer: reader.buffer, offset: reader.offset)) + let decoder = _BSATNDecoder(storage: wrapper, codingPath: []) + return try T(from: decoder) + } + } + + public func decode(_ type: String.Type, from data: Data) throws -> String { + return try data.withUnsafeBytes { buffer in + var reader = BSATNReader(buffer: buffer) + return try reader.readString() + } + } +} + +struct _BSATNDecoder: Decoder { + var storage: BSATNReaderWrapper + var codingPath: [CodingKey] + var userInfo: [CodingUserInfoKey: Any] = [:] + + func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey { + return KeyedDecodingContainer(KeyedBSATNDecodingContainer(decoder: self)) + } + + func unkeyedContainer() throws -> UnkeyedDecodingContainer { + return UnkeyedBSATNDecodingContainer(decoder: self) + } + + func singleValueContainer() throws -> SingleValueDecodingContainer { + return SingleValueBSATNDecodingContainer(decoder: self) + } +} + +struct KeyedBSATNDecodingContainer: KeyedDecodingContainerProtocol { + var decoder: _BSATNDecoder + var codingPath: [CodingKey] { decoder.codingPath } + var allKeys: [Key] = [] + + func contains(_ key: Key) -> Bool { + return true + } + + func decodeNil(forKey key: Key) throws -> Bool { + let tag = try decoder.storage.withReader { try $0.readU8() } + switch tag { + case 0: return false + case 1: return true + default: throw BSATNDecodingError.invalidType + } + } + + func decode(_ type: T.Type, forKey key: Key) throws -> T { + if let specialType = type as? BSATNSpecialDecodable.Type { + return try decoder.storage.withReader { try specialType.decodeBSATN(from: &$0) } as! T + } + let childDecoder = _BSATNDecoder(storage: decoder.storage, codingPath: codingPath + [key]) + return try T(from: childDecoder) + } + + func decodeIfPresent(_ type: T.Type, forKey key: Key) throws -> T? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + if let specialType = type as? BSATNSpecialDecodable.Type { + return try decoder.storage.withReader { try specialType.decodeBSATN(from: &$0) } as? T + } + let childDecoder = _BSATNDecoder(storage: decoder.storage, codingPath: codingPath + [key]) + return try T(from: childDecoder) + } + + func decodeIfPresent(_ type: Bool.Type, forKey key: Key) throws -> Bool? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return try decoder.storage.withReader { try $0.readBool() } + } + + func decodeIfPresent(_ type: String.Type, forKey key: Key) throws -> String? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return try decoder.storage.withReader { try $0.readString() } + } + + func decodeIfPresent(_ type: Float.Type, forKey key: Key) throws -> Float? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return try decoder.storage.withReader { try $0.readFloat() } + } + + func decodeIfPresent(_ type: Double.Type, forKey key: Key) throws -> Double? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return try decoder.storage.withReader { try $0.readDouble() } + } + + func decodeIfPresent(_ type: Int.Type, forKey key: Key) throws -> Int? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return Int(try decoder.storage.withReader { try $0.readI64() }) + } + + func decodeIfPresent(_ type: Int8.Type, forKey key: Key) throws -> Int8? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return try decoder.storage.withReader { try $0.readI8() } + } + + func decodeIfPresent(_ type: Int16.Type, forKey key: Key) throws -> Int16? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return try decoder.storage.withReader { try $0.readI16() } + } + + func decodeIfPresent(_ type: Int32.Type, forKey key: Key) throws -> Int32? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return try decoder.storage.withReader { try $0.readI32() } + } + + func decodeIfPresent(_ type: Int64.Type, forKey key: Key) throws -> Int64? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return try decoder.storage.withReader { try $0.readI64() } + } + + func decodeIfPresent(_ type: UInt.Type, forKey key: Key) throws -> UInt? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return UInt(try decoder.storage.withReader { try $0.readU64() }) + } + + func decodeIfPresent(_ type: UInt8.Type, forKey key: Key) throws -> UInt8? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return try decoder.storage.withReader { try $0.readU8() } + } + + func decodeIfPresent(_ type: UInt16.Type, forKey key: Key) throws -> UInt16? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return try decoder.storage.withReader { try $0.readU16() } + } + + func decodeIfPresent(_ type: UInt32.Type, forKey key: Key) throws -> UInt32? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return try decoder.storage.withReader { try $0.readU32() } + } + + func decodeIfPresent(_ type: UInt64.Type, forKey key: Key) throws -> UInt64? { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { return nil } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return try decoder.storage.withReader { try $0.readU64() } + } + + func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer where NestedKey : CodingKey { + let childDecoder = _BSATNDecoder(storage: decoder.storage, codingPath: codingPath + [key]) + return try childDecoder.container(keyedBy: type) + } + + func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { + let childDecoder = _BSATNDecoder(storage: decoder.storage, codingPath: codingPath + [key]) + return try childDecoder.unkeyedContainer() + } + + func superDecoder() throws -> Decoder { + return _BSATNDecoder(storage: decoder.storage, codingPath: codingPath) + } + + func superDecoder(forKey key: Key) throws -> Decoder { + return _BSATNDecoder(storage: decoder.storage, codingPath: codingPath + [key]) + } +} + +struct UnkeyedBSATNDecodingContainer: UnkeyedDecodingContainer { + var decoder: _BSATNDecoder + var codingPath: [CodingKey] { decoder.codingPath } + + var count: Int? = nil + var isAtEnd: Bool { + return decoder.storage.withReader { $0.isAtEnd } + } + var currentIndex: Int = 0 + + mutating func decodeNil() throws -> Bool { + let tag = try decoder.storage.withReader { try $0.readU8() } + if tag == 1 { + currentIndex += 1 + return true + } + guard tag == 0 else { throw BSATNDecodingError.invalidType } + return false + } + + mutating func decode(_ type: T.Type) throws -> T { + let value: T + if let specialType = type as? BSATNSpecialDecodable.Type { + value = try decoder.storage.withReader { try specialType.decodeBSATN(from: &$0) } as! T + } else { + let childDecoder = _BSATNDecoder(storage: decoder.storage, codingPath: codingPath) + value = try T(from: childDecoder) + } + currentIndex += 1 + return value + } + + mutating func nestedContainer(keyedBy type: NestedKey.Type) throws -> KeyedDecodingContainer where NestedKey : CodingKey { + fatalError("Nested containers in unkeyed containers not supported by pure BSATN.") + } + + mutating func nestedUnkeyedContainer() throws -> UnkeyedDecodingContainer { + let childDecoder = _BSATNDecoder(storage: decoder.storage, codingPath: codingPath) + currentIndex += 1 + return try childDecoder.unkeyedContainer() + } + + mutating func superDecoder() throws -> Decoder { + return _BSATNDecoder(storage: decoder.storage, codingPath: codingPath) + } +} + +struct SingleValueBSATNDecodingContainer: SingleValueDecodingContainer { + var decoder: _BSATNDecoder + var codingPath: [CodingKey] { decoder.codingPath } + + func decodeNil() -> Bool { + guard let tag = try? decoder.storage.reader.readU8() else { return false } + return tag == 1 + } + + func decode(_ type: Bool.Type) throws -> Bool { return try decoder.storage.withReader { try $0.readBool() } } + func decode(_ type: String.Type) throws -> String { return try decoder.storage.withReader { try $0.readString() } } + func decode(_ type: Double.Type) throws -> Double { return try decoder.storage.withReader { try $0.readDouble() } } + func decode(_ type: Float.Type) throws -> Float { return try decoder.storage.withReader { try $0.readFloat() } } + func decode(_ type: Int.Type) throws -> Int { return Int(try decoder.storage.withReader { try $0.readI64() }) } + func decode(_ type: Int8.Type) throws -> Int8 { return try decoder.storage.withReader { try $0.readI8() } } + func decode(_ type: Int16.Type) throws -> Int16 { return try decoder.storage.withReader { try $0.readI16() } } + func decode(_ type: Int32.Type) throws -> Int32 { return try decoder.storage.withReader { try $0.readI32() } } + func decode(_ type: Int64.Type) throws -> Int64 { return try decoder.storage.withReader { try $0.readI64() } } + func decode(_ type: UInt.Type) throws -> UInt { return UInt(try decoder.storage.withReader { try $0.readU64() }) } + func decode(_ type: UInt8.Type) throws -> UInt8 { return try decoder.storage.withReader { try $0.readU8() } } + func decode(_ type: UInt16.Type) throws -> UInt16 { return try decoder.storage.withReader { try $0.readU16() } } + func decode(_ type: UInt32.Type) throws -> UInt32 { return try decoder.storage.withReader { try $0.readU32() } } + func decode(_ type: UInt64.Type) throws -> UInt64 { return try decoder.storage.withReader { try $0.readU64() } } + + func decode(_ type: T.Type) throws -> T { + if let specialType = type as? BSATNSpecialDecodable.Type { + return try decoder.storage.withReader { try specialType.decodeBSATN(from: &$0) } as! T + } + let childDecoder = _BSATNDecoder(storage: decoder.storage, codingPath: codingPath) + return try T(from: childDecoder) + } +} + +@_documentation(visibility: internal) +public protocol BSATNSpecialDecodable { + static func decodeBSATN(from reader: inout BSATNReader) throws -> Self +} + +extension Array: BSATNSpecialDecodable where Element: Decodable { + public static func decodeBSATN(from reader: inout BSATNReader) throws -> Array { + let count = Int(try reader.readU32()) + if count == 0 { + return [] + } + + #if _endian(little) + if Element.self is any BSATNFastCopyable.Type { + let stride = MemoryLayout.stride + let byteCount = count * stride + guard reader.offset + byteCount <= reader.buffer.count else { + throw BSATNDecodingError.unexpectedEndOfData + } + let src = reader.buffer.baseAddress!.advanced(by: reader.offset) + let values: [Element] = Array(unsafeUninitializedCapacity: count) { bufferOut, initializedCount in + memcpy(bufferOut.baseAddress!, src, byteCount) + initializedCount = count + } + reader.offset += byteCount + return values + } + #endif + + var decoded = Array() + decoded.reserveCapacity(count) + for _ in 0.. Optional { + let tag = try reader.readU8() + if tag == 1 { + return .none + } else if tag == 0 { + if let specialType = Wrapped.self as? BSATNSpecialDecodable.Type { + return .some(try specialType.decodeBSATN(from: &reader) as! Wrapped) + } + let wrapper = BSATNReaderWrapper(reader: BSATNReader(buffer: reader.buffer, offset: reader.offset)) + let decoder = _BSATNDecoder(storage: wrapper, codingPath: []) + let result: Optional = .some(try Wrapped(from: decoder)) + reader.offset = wrapper.reader.offset + return result + } else { + throw BSATNDecodingError.invalidType + } + } +} diff --git a/sdks/swift/Sources/SpacetimeDB/BSATN/BSATNEncoder.swift b/sdks/swift/Sources/SpacetimeDB/BSATN/BSATNEncoder.swift new file mode 100644 index 00000000000..57a5800fb18 --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/BSATN/BSATNEncoder.swift @@ -0,0 +1,460 @@ +import Foundation + +@_documentation(visibility: internal) +public enum BSATNEncodingError: Error, Equatable, BitwiseCopyable { + case lengthOutOfRange +} + +@_documentation(visibility: internal) +public struct BSATNStorage: ~Copyable { + public var data: Data + + public init(data: Data = Data()) { + self.data = data + } + + public mutating func append(_ newBytes: Data) { + data.append(newBytes) + } + + public mutating func append(_ value: T) { + var littleEndian = value.littleEndian + withUnsafeBytes(of: &littleEndian) { buffer in + data.append(buffer.bindMemory(to: UInt8.self)) + } + } + + public mutating func appendU8(_ value: UInt8) { + data.append(value) + } + + public mutating func appendU16(_ value: UInt16) { + var val = value.littleEndian + withUnsafeBytes(of: &val) { data.append($0.bindMemory(to: UInt8.self)) } + } + + public mutating func appendU32(_ value: UInt32) { + var val = value.littleEndian + withUnsafeBytes(of: &val) { data.append($0.bindMemory(to: UInt8.self)) } + } + + public mutating func appendU64(_ value: UInt64) { + var val = value.littleEndian + withUnsafeBytes(of: &val) { data.append($0.bindMemory(to: UInt8.self)) } + } + + public mutating func appendI8(_ value: Int8) { + data.append(UInt8(bitPattern: value)) + } + + public mutating func appendI16(_ value: Int16) { + var val = value.littleEndian + withUnsafeBytes(of: &val) { data.append($0.bindMemory(to: UInt8.self)) } + } + + public mutating func appendI32(_ value: Int32) { + var val = value.littleEndian + withUnsafeBytes(of: &val) { data.append($0.bindMemory(to: UInt8.self)) } + } + + public mutating func appendI64(_ value: Int64) { + var val = value.littleEndian + withUnsafeBytes(of: &val) { data.append($0.bindMemory(to: UInt8.self)) } + } + + public mutating func appendString(_ value: String) throws(BSATNEncodingError) { + let utf8 = Data(value.utf8) + guard utf8.count <= Int(UInt32.max) else { + throw .lengthOutOfRange + } + append(UInt32(utf8.count)) + append(utf8) + } + + public mutating func appendBool(_ value: Bool) { + append(value ? 1 as UInt8 : 0 as UInt8) + } + + public mutating func appendFloat(_ value: Float) { + append(value.bitPattern) + } + + + public mutating func appendDouble(_ value: Double) { + append(value.bitPattern) + } + + public mutating func fallbackEncode(_ value: T) throws { + let wrapper = BSATNStorageWrapper(storage: BSATNStorage(data: self.data)) + self.data = Data() + let encoder = _BSATNEncoder(storage: wrapper, codingPath: []) + try value.encode(to: encoder) + self.data = wrapper.storage.data + } +} + +class BSATNStorageWrapper { + var storage: BSATNStorage + init(storage: consuming BSATNStorage) { + self.storage = storage + } + + func withStorage(_ block: (inout BSATNStorage) throws -> T) rethrows -> T { + try block(&storage) + } +} + +@_documentation(visibility: internal) +public final class BSATNEncoder: Sendable { + public init() {} + + public func encode(_ value: T) throws -> Data { + var storage = BSATNStorage() + if let bsatnSpecial = value as? BSATNSpecialEncodable { + try bsatnSpecial.encodeBSATN(to: &storage) + } else { + let wrapper = BSATNStorageWrapper(storage: BSATNStorage(data: storage.data)) + storage.data = Data() + let encoder = _BSATNEncoder(storage: wrapper, codingPath: []) + try value.encode(to: encoder) + storage.data = wrapper.storage.data + } + return storage.data + } +} + +struct _BSATNEncoder: Encoder { + var storage: BSATNStorageWrapper + var codingPath: [CodingKey] + var userInfo: [CodingUserInfoKey: Any] = [:] + + init(storage: BSATNStorageWrapper, codingPath: [CodingKey] = []) { + self.storage = storage + self.codingPath = codingPath + } + + var data: Data { storage.withStorage { $0.data } } + + func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key : CodingKey { + return KeyedEncodingContainer(KeyedBSATNEncodingContainer(encoder: self)) + } + + func unkeyedContainer() -> UnkeyedEncodingContainer { + return UnkeyedBSATNEncodingContainer(encoder: self) + } + + func singleValueContainer() -> SingleValueEncodingContainer { + return SingleValueBSATNEncodingContainer(encoder: self) + } +} + +struct KeyedBSATNEncodingContainer: KeyedEncodingContainerProtocol { + var encoder: _BSATNEncoder + var codingPath: [CodingKey] { encoder.codingPath } + + mutating func encodeNil(forKey key: Key) throws { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + + mutating func encodeIfPresent(_ value: T?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + if let bsatnSpecial = value as? BSATNSpecialEncodable { + try bsatnSpecial.encodeBSATN(to: &encoder.storage.storage) + } else { + let childEncoder = _BSATNEncoder(storage: encoder.storage, codingPath: codingPath + [key]) + try value.encode(to: childEncoder) + } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + mutating func encodeIfPresent(_ value: Bool?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + encoder.storage.withStorage { $0.appendBool(value) } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + mutating func encodeIfPresent(_ value: String?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + try encoder.storage.withStorage { try $0.appendString(value) } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + mutating func encodeIfPresent(_ value: Float?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + encoder.storage.withStorage { $0.appendFloat(value) } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + mutating func encodeIfPresent(_ value: Double?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + encoder.storage.withStorage { $0.appendDouble(value) } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + mutating func encodeIfPresent(_ value: Int?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + encoder.storage.withStorage { $0.append(Int64(value)) } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + mutating func encodeIfPresent(_ value: Int8?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + encoder.storage.withStorage { $0.append(value) } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + mutating func encodeIfPresent(_ value: Int16?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + encoder.storage.withStorage { $0.append(value) } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + mutating func encodeIfPresent(_ value: Int32?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + encoder.storage.withStorage { $0.append(value) } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + mutating func encodeIfPresent(_ value: Int64?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + encoder.storage.withStorage { $0.append(value) } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + mutating func encodeIfPresent(_ value: UInt?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + encoder.storage.withStorage { $0.append(UInt64(value)) } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + mutating func encodeIfPresent(_ value: UInt8?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + encoder.storage.withStorage { $0.append(value) } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + mutating func encodeIfPresent(_ value: UInt16?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + encoder.storage.withStorage { $0.append(value) } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + mutating func encodeIfPresent(_ value: UInt32?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + encoder.storage.withStorage { $0.append(value) } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + mutating func encodeIfPresent(_ value: UInt64?, forKey key: Key) throws { + if let value = value { + encoder.storage.withStorage { $0.append(0 as UInt8) } + encoder.storage.withStorage { $0.append(value) } + } else { + encoder.storage.withStorage { $0.append(1 as UInt8) } + } + } + + public func encode(_ value: T, forKey key: Key) throws { + if let bsatnSpecial = value as? BSATNSpecialEncodable { + try bsatnSpecial.encodeBSATN(to: &encoder.storage.storage) + } else { + let childEncoder = _BSATNEncoder(storage: encoder.storage, codingPath: codingPath + [key]) + try value.encode(to: childEncoder) + } + } + + mutating func encodeConditional(_ object: T, forKey key: Key) throws { + try encode(object, forKey: key) + } + + mutating func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer where NestedKey : CodingKey { + let childEncoder = _BSATNEncoder(storage: encoder.storage, codingPath: codingPath + [key]) + return childEncoder.container(keyedBy: keyType) + } + + mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { + let childEncoder = _BSATNEncoder(storage: encoder.storage, codingPath: codingPath + [key]) + return childEncoder.unkeyedContainer() + } + + mutating func superEncoder() -> Encoder { + return _BSATNEncoder(storage: encoder.storage, codingPath: codingPath) + } + + mutating func superEncoder(forKey key: Key) -> Encoder { + return _BSATNEncoder(storage: encoder.storage, codingPath: codingPath + [key]) + } +} + +struct UnkeyedBSATNEncodingContainer: UnkeyedEncodingContainer { + var encoder: _BSATNEncoder + var codingPath: [CodingKey] { encoder.codingPath } + var count: Int = 0 + + mutating func encodeNil() throws { + encoder.storage.withStorage { $0.append(1 as UInt8) } + count += 1 + } + + mutating func encode(_ value: T) throws { + if let bsatnSpecial = value as? BSATNSpecialEncodable { + try bsatnSpecial.encodeBSATN(to: &encoder.storage.storage) + } else { + let childEncoder = _BSATNEncoder(storage: encoder.storage, codingPath: codingPath) + try value.encode(to: childEncoder) + } + count += 1 + } + + mutating func nestedContainer(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer where NestedKey : CodingKey { + fatalError("Nested containers in unkeyed containers not supported by pure BSATN.") + } + + mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { + let childEncoder = _BSATNEncoder(storage: encoder.storage, codingPath: codingPath) + count += 1 + return childEncoder.unkeyedContainer() + } + + mutating func superEncoder() -> Encoder { + return _BSATNEncoder(storage: encoder.storage, codingPath: codingPath) + } +} + +struct SingleValueBSATNEncodingContainer: SingleValueEncodingContainer { + var encoder: _BSATNEncoder + var codingPath: [CodingKey] { encoder.codingPath } + + mutating func encodeNil() throws { encoder.storage.withStorage { $0.append(1 as UInt8) } } + mutating func encode(_ value: Bool) throws { encoder.storage.withStorage { $0.appendBool(value) } } + mutating func encode(_ value: String) throws { try encoder.storage.withStorage { try $0.appendString(value) } } + mutating func encode(_ value: Double) throws { encoder.storage.withStorage { $0.appendDouble(value) } } + mutating func encode(_ value: Float) throws { encoder.storage.withStorage { $0.appendFloat(value) } } + mutating func encode(_ value: Int) throws { encoder.storage.withStorage { $0.append(Int64(value)) } } + mutating func encode(_ value: Int8) throws { encoder.storage.withStorage { $0.append(value) } } + mutating func encode(_ value: Int16) throws { encoder.storage.withStorage { $0.append(value) } } + mutating func encode(_ value: Int32) throws { encoder.storage.withStorage { $0.append(value) } } + mutating func encode(_ value: Int64) throws { encoder.storage.withStorage { $0.append(value) } } + mutating func encode(_ value: UInt) throws { encoder.storage.withStorage { $0.append(UInt64(value)) } } + mutating func encode(_ value: UInt8) throws { encoder.storage.withStorage { $0.append(value) } } + mutating func encode(_ value: UInt16) throws { encoder.storage.withStorage { $0.append(value) } } + mutating func encode(_ value: UInt32) throws { encoder.storage.withStorage { $0.append(value) } } + mutating func encode(_ value: UInt64) throws { encoder.storage.withStorage { $0.append(value) } } + + mutating func encode(_ value: T) throws { + if let bsatnSpecial = value as? BSATNSpecialEncodable { + try bsatnSpecial.encodeBSATN(to: &encoder.storage.storage) + } else { + let childEncoder = _BSATNEncoder(storage: encoder.storage, codingPath: codingPath) + try value.encode(to: childEncoder) + } + } +} + +@_documentation(visibility: internal) +public protocol BSATNSpecialEncodable { + func encodeBSATN(to storage: inout BSATNStorage) throws +} + +extension Array: BSATNSpecialEncodable where Element: Encodable { + public func encodeBSATN(to storage: inout BSATNStorage) throws { + guard self.count <= Int(UInt32.max) else { + throw BSATNEncodingError.lengthOutOfRange + } + storage.appendU32(UInt32(self.count)) + if self.isEmpty { return } + + + #if _endian(little) + if Element.self is any BSATNFastCopyable.Type { + self.withUnsafeBytes { raw in + if let base = raw.baseAddress { + storage.data.append(base.assumingMemoryBound(to: UInt8.self), count: raw.count) + } + } + return + } + #endif + + if Element.self is BSATNSpecialEncodable.Type { + for element in self { + try (element as! BSATNSpecialEncodable).encodeBSATN(to: &storage) + } + return + } + + let wrapper = BSATNStorageWrapper(storage: BSATNStorage(data: storage.data)) + storage.data = Data() + let encoder = _BSATNEncoder(storage: wrapper, codingPath: []) + for element in self { + if let bsatnSpecial = element as? BSATNSpecialEncodable { + try bsatnSpecial.encodeBSATN(to: &wrapper.storage) + } else { + try element.encode(to: encoder) + } + } + storage.data = wrapper.storage.data + } +} + +extension Optional: BSATNSpecialEncodable where Wrapped: Encodable { + public func encodeBSATN(to storage: inout BSATNStorage) throws { + switch self { + case .none: + storage.append(1 as UInt8) + case .some(let wrapped): + storage.append(0 as UInt8) + if let bsatnSpecial = wrapped as? BSATNSpecialEncodable { + try bsatnSpecial.encodeBSATN(to: &storage) + } else { + let wrapper = BSATNStorageWrapper(storage: BSATNStorage(data: storage.data)) + storage.data = Data() + let encoder = _BSATNEncoder(storage: wrapper, codingPath: []) + try wrapped.encode(to: encoder) + storage.data = wrapper.storage.data + } + } + } +} diff --git a/sdks/swift/Sources/SpacetimeDB/Cache/ClientCache.swift b/sdks/swift/Sources/SpacetimeDB/Cache/ClientCache.swift new file mode 100644 index 00000000000..8dc3fe734d9 --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/Cache/ClientCache.swift @@ -0,0 +1,192 @@ +import Foundation +import Synchronization + +/// Holds the local state of all SpacetimeDB tables, routing updates from the WebSocket down to each table. +public final class ClientCache: Sendable { + private struct State { + var tables: [String: any SpacetimeTableCacheProtocol] = [:] + } + + private let state: Mutex = Mutex(State()) + + public var registeredTableNames: [String] { + state.withLock { state in + Array(state.tables.keys) + } + } + + public init() {} + + /// Registers a new table cache for a given table name. + public func registerTable(tableName: String, rowType: T.Type) { + state.withLock { state in + if let existing = state.tables[tableName] { + if existing is TableCache { + // Idempotent re-registration: keep the existing cache instance so + // any replicated rows already loaded are preserved. + return + } + fatalError("Table \(tableName) already registered with a different row type.") + } + let cache = TableCache(tableName: tableName) + state.tables[tableName] = cache + } + } + + /// Registers a new table cache for a given table name. + public func registerTable(name: String, cache: TableCache) { + state.withLock { state in + if state.tables[name] != nil { + // Preserve the first registration to avoid replacing a live cache. + return + } + state.tables[name] = cache + } + } + + public func getTable(name: String) -> (any SpacetimeTableCacheProtocol)? { + state.withLock { state in + state.tables[name] + } + } + + public func getTableCache(tableName: String) -> TableCache { + guard let table = state.withLock({ state in + state.tables[tableName] as? TableCache + }) else { + fatalError("Table \(tableName) not registered or of wrong type.") + } + return table + } + + /// Processes a TransactionUpdate payload from the network. + public func applyTransactionUpdate(_ update: TransactionUpdate) { + var modifiedTables = Set() + + let tablesSnapshot = state.withLock { state in + state.tables + } + + for querySet in update.querySets { + for tableUpdate in querySet.tables { + let tableName = tableUpdate.tableName.rawValue + guard let tableCache = tablesSnapshot[tableName] else { + continue + } + + modifiedTables.insert(tableName) + + for rowUpdate in tableUpdate.rows { + switch rowUpdate { + case .persistentTable(let persistent): + let deleteRows = self.extractRows(from: persistent.deletes) + let insertRows = self.extractRows(from: persistent.inserts) + let pairedCount = min(deleteRows.count, insertRows.count) + + + if pairedCount > 0 { + do { + try tableCache.handleBulkUpdate( + oldRowBytesList: Array(deleteRows[.. pairedCount { + do { + try tableCache.handleBulkDelete(rowBytesList: Array(deleteRows[pairedCount...])) + } catch { + Log.cache.error("Failed to bulk delete rows for table '\(tableName)': \(error.localizedDescription)") + } + } + if insertRows.count > pairedCount { + do { + try tableCache.handleBulkInsert(rowBytesList: Array(insertRows[pairedCount...])) + } catch { + Log.cache.error("Failed to bulk insert rows for table '\(tableName)': \(error.localizedDescription)") + } + } + case .eventTable: + break + } + } + } + } + + // After all background processing is done, sync modified tables to MainActor for UI observers + if !modifiedTables.isEmpty { + Task { @MainActor in + for tableName in modifiedTables { + if let table = tablesSnapshot[tableName] as? (any ThreadSafeSyncable) { + table.sync() + } + } + } + } + } + + private func extractRows(from rowList: BsatnRowList) -> [Data] { + let sizeHint = rowList.sizeHint + let data = rowList.rowsData + var rows: [Data] = [] + + switch sizeHint { + case .fixedSize(let size): + let rowSize = Int(size) + if rowSize == 0 { return rows } + + var offset = 0 + while offset < data.count { + let end = min(offset + rowSize, data.count) + rows.append(data[offset..(_ rows: S, tableCache: any SpacetimeTableCacheProtocol, isInsert: Bool) + where S.Element == Data { + for rowData in rows { + self.applyRow(data: rowData, tableCache: tableCache, isInsert: isInsert) + } + } + + private func applyRow(data: Data, tableCache: any SpacetimeTableCacheProtocol, isInsert: Bool) { + do { + if isInsert { + try tableCache.handleInsert(rowBytes: data) + } else { + try tableCache.handleDelete(rowBytes: data) + } + } catch { + Log.cache.error("Failed to decode row for table '\(tableCache.tableName)': \(error.localizedDescription)") + } + } + + private func applyRowUpdate(oldData: Data, newData: Data, tableCache: any SpacetimeTableCacheProtocol) { + do { + try tableCache.handleUpdate(oldRowBytes: oldData, newRowBytes: newData) + } catch { + Log.cache.error("Failed to decode updated row for table '\(tableCache.tableName)': \(error.localizedDescription)") + } + } +} + +/// Helper protocol to allow ClientCache to call sync() without knowing the concrete type T +private protocol ThreadSafeSyncable { + @MainActor func sync() +} + +extension TableCache: ThreadSafeSyncable {} diff --git a/sdks/swift/Sources/SpacetimeDB/Cache/TableCache.swift b/sdks/swift/Sources/SpacetimeDB/Cache/TableCache.swift new file mode 100644 index 00000000000..f5a7f45a84a --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/Cache/TableCache.swift @@ -0,0 +1,389 @@ +import Foundation +import Observation +import Synchronization + +public protocol SpacetimeTableCacheProtocol: AnyObject, Sendable { + var tableName: String { get } + func handleInsert(rowBytes: Data) throws + func handleDelete(rowBytes: Data) throws + func handleUpdate(oldRowBytes: Data, newRowBytes: Data) throws + func handleBulkInsert(rowBytesList: [Data]) throws + func handleBulkDelete(rowBytesList: [Data]) throws + func handleBulkUpdate(oldRowBytesList: [Data], newRowBytesList: [Data]) throws + func clear() +} + +public final class TableDeltaHandle: Sendable { + private let cancelAction: @Sendable () -> Void + private let cancelledState: Mutex = Mutex(false) + + init(cancelAction: @escaping @Sendable () -> Void) { + self.cancelAction = cancelAction + } + + public func cancel() { + let shouldCancel = cancelledState.withLock { isCancelled in + guard !isCancelled else { return false } + isCancelled = true + return true + } + + if shouldCancel { + cancelAction() + } + } +} + +// MARK: - HashedBytes + +/// A Data wrapper that pre-computes its hash on construction. +/// Dictionary lookups hash 8 bytes (the cached Int) instead of N row bytes. +struct HashedBytes: Hashable, Sendable { + let data: Data + private let _hash: Int + + init(_ data: Data) { + self.data = data + self._hash = data.hashValue // Native SipHash, much faster + } + + func hash(into hasher: inout Hasher) { + hasher.combine(_hash) + } + + static func == (lhs: HashedBytes, rhs: HashedBytes) -> Bool { + lhs._hash == rhs._hash && lhs.data == rhs.data + } +} + +// MARK: - RowEntry + +/// Merged storage: count + decoded value in a single dictionary entry. +/// Eliminates the second dictionary lookup per insert/delete. +struct RowEntry { + var count: Int + var value: T +} + +// MARK: - TableCache + +/// A reactive, thread-safe cache containing the local replica array of persistent rows for a given SpacetimeDB table +@Observable +public final class TableCache: SpacetimeTableCacheProtocol, Sendable { + public let tableName: String + + // For SwiftUI observability via @Observable + // This is only updated on the MainActor when requested or via sync() + @MainActor + public private(set) var rows: [T] = [] + + // All internal state is @ObservationIgnored to prevent the @Observable macro + // from intercepting every dictionary mutation with willSet/didSet tracking. + // Only `rows` is observed for SwiftUI reactivity. + @ObservationIgnored private let decoder = BSATNDecoder() + @ObservationIgnored private let state: Mutex = Mutex(State()) + + private struct State { + var entries: [HashedBytes: RowEntry] = [:] + var insertCallbacks: [UUID: @Sendable (T) -> Void] = [:] + var deleteCallbacks: [UUID: @Sendable (T) -> Void] = [:] + var updateCallbacks: [UUID: @Sendable (T, T) -> Void] = [:] + } + + public init(tableName: String) { + self.tableName = tableName + } + + public func handleInsert(rowBytes: Data) throws { + let key = HashedBytes(rowBytes) + let rowAndCallbacks: (row: T, callbacks: [@Sendable (T) -> Void]) + do { + rowAndCallbacks = try state.withLock { state in + let row: T + if let index = state.entries.index(forKey: key) { + state.entries.values[index].count += 1 + row = state.entries.values[index].value + } else { + row = try decoder.decode(T.self, from: rowBytes) + state.entries[key] = RowEntry(count: 1, value: row) + } + return (row: row, callbacks: Array(state.insertCallbacks.values)) + } + } catch { + Log.cache.error("Failed to decode row for table '\(self.tableName)': \(error.localizedDescription)") + throw error + } + + for callback in rowAndCallbacks.callbacks { + callback(rowAndCallbacks.row) + } + } + + public func handleDelete(rowBytes: Data) throws { + let key = HashedBytes(rowBytes) + let rowAndCallbacks = state.withLock { state -> (row: T, callbacks: [@Sendable (T) -> Void])? in + guard let index = state.entries.index(forKey: key) else { + return nil + } + let deletedRow = state.entries.values[index].value + if state.entries.values[index].count <= 1 { + state.entries.remove(at: index) + } else { + state.entries.values[index].count -= 1 + } + return (row: deletedRow, callbacks: Array(state.deleteCallbacks.values)) + } + + guard let rowAndCallbacks else { + return + } + + for callback in rowAndCallbacks.callbacks { + callback(rowAndCallbacks.row) + } + } + + public func handleUpdate(oldRowBytes: Data, newRowBytes: Data) throws { + let oldKey = HashedBytes(oldRowBytes) + let newKey = HashedBytes(newRowBytes) + let rowAndCallbacks: (oldRow: T, newRow: T, callbacks: [@Sendable (T, T) -> Void])? + do { + rowAndCallbacks = try state.withLock { state in + guard let oldIndex = state.entries.index(forKey: oldKey) else { + return nil + } + + let oldRow = state.entries.values[oldIndex].value + let newRow: T + + if let newIndex = state.entries.index(forKey: newKey) { + state.entries.values[newIndex].count += 1 + newRow = state.entries.values[newIndex].value + } else { + newRow = try decoder.decode(T.self, from: newRowBytes) + state.entries[newKey] = RowEntry(count: 1, value: newRow) + } + + if let finalOldIndex = state.entries.index(forKey: oldKey) { + if state.entries.values[finalOldIndex].count <= 1 { + state.entries.remove(at: finalOldIndex) + } else { + state.entries.values[finalOldIndex].count -= 1 + } + } + + return (oldRow: oldRow, newRow: newRow, callbacks: Array(state.updateCallbacks.values)) + } + } catch { + Log.cache.error("Failed to decode row for table '\(self.tableName)': \(error.localizedDescription)") + throw error + } + + guard let rowAndCallbacks else { + try handleInsert(rowBytes: newRowBytes) + return + } + + for callback in rowAndCallbacks.callbacks { + callback(rowAndCallbacks.oldRow, rowAndCallbacks.newRow) + } + } + + + public func handleBulkInsert(rowBytesList: [Data]) throws { + if rowBytesList.isEmpty { return } + + let rowsAndCallbacks: [(row: T, callbacks: [@Sendable (T) -> Void])] = try state.withLock { state in + var results: [(row: T, callbacks: [@Sendable (T) -> Void])] = [] + results.reserveCapacity(rowBytesList.count) + let callbacks = Array(state.insertCallbacks.values) + + for rowBytes in rowBytesList { + let key = HashedBytes(rowBytes) + let row: T + if let index = state.entries.index(forKey: key) { + state.entries.values[index].count += 1 + row = state.entries.values[index].value + } else { + row = try decoder.decode(T.self, from: rowBytes) + state.entries[key] = RowEntry(count: 1, value: row) + } + results.append((row: row, callbacks: callbacks)) + } + return results + } + + for item in rowsAndCallbacks { + for callback in item.callbacks { + callback(item.row) + } + } + } + + public func handleBulkDelete(rowBytesList: [Data]) throws { + if rowBytesList.isEmpty { return } + + let rowsAndCallbacks: [(row: T, callbacks: [@Sendable (T) -> Void])] = state.withLock { state in + var results: [(row: T, callbacks: [@Sendable (T) -> Void])] = [] + results.reserveCapacity(rowBytesList.count) + let callbacks = Array(state.deleteCallbacks.values) + + for rowBytes in rowBytesList { + let key = HashedBytes(rowBytes) + if let index = state.entries.index(forKey: key) { + let deletedRow = state.entries.values[index].value + if state.entries.values[index].count <= 1 { + state.entries.remove(at: index) + } else { + state.entries.values[index].count -= 1 + } + results.append((row: deletedRow, callbacks: callbacks)) + } + } + return results + } + + for item in rowsAndCallbacks { + for callback in item.callbacks { + callback(item.row) + } + } + } + + public func handleBulkUpdate(oldRowBytesList: [Data], newRowBytesList: [Data]) throws { + if oldRowBytesList.isEmpty || newRowBytesList.isEmpty { return } + let count = min(oldRowBytesList.count, newRowBytesList.count) + + let results: [(oldRow: T, newRow: T, callbacks: [@Sendable (T, T) -> Void])]? = try state.withLock { state in + var results: [(oldRow: T, newRow: T, callbacks: [@Sendable (T, T) -> Void])] = [] + results.reserveCapacity(count) + let callbacks = Array(state.updateCallbacks.values) + + for i in 0.. Void) -> TableDeltaHandle { + let id = UUID() + state.withLock { state in + state.insertCallbacks[id] = callback + } + return TableDeltaHandle { [weak self] in + guard let self else { return } + _ = self.state.withLock { state in + state.insertCallbacks.removeValue(forKey: id) + } + } + } + + @discardableResult + public func onDelete(_ callback: @escaping @Sendable (T) -> Void) -> TableDeltaHandle { + let id = UUID() + state.withLock { state in + state.deleteCallbacks[id] = callback + } + return TableDeltaHandle { [weak self] in + guard let self else { return } + _ = self.state.withLock { state in + state.deleteCallbacks.removeValue(forKey: id) + } + } + } + + @discardableResult + public func onUpdate(_ callback: @escaping @Sendable (T, T) -> Void) -> TableDeltaHandle { + let id = UUID() + state.withLock { state in + state.updateCallbacks[id] = callback + } + return TableDeltaHandle { [weak self] in + guard let self else { return } + _ = self.state.withLock { state in + state.updateCallbacks.removeValue(forKey: id) + } + } + } + + /// Synchronizes the observable `rows` array with the internal background storage. + /// This should be called on the MainActor, typically after a transaction update or when the UI needs refreshing. + @MainActor + public func sync() { + self.rows = snapshot() + } + + /// Internal method to generate a flattened snapshot of all rows in deterministic order. + private func snapshot() -> [T] { + state.withLock { state in + var flattened: [T] = [] + flattened.reserveCapacity(state.entries.count) // Baseline capacity + for entry in state.entries.values { + if entry.count > 0 { + flattened.reserveCapacity(flattened.count + entry.count) + for _ in 0.. Logger { + switch category { + case "Client": + return clientLogger + case "Cache": + return cacheLogger + case "Network": + return networkLogger + default: + return clientLogger + } + } +} + +public enum SpacetimeObservability { + public nonisolated(unsafe) static var logger: any SpacetimeLogger = OSLogSpacetimeLogger() + public nonisolated(unsafe) static var metrics: any SpacetimeMetrics = NoopSpacetimeMetrics() +} + +enum Log { + static let client = LogCategory("Client") + static let cache = LogCategory("Cache") + static let network = LogCategory("Network") +} + +struct LogCategory { + private let category: String + + init(_ category: String) { + self.category = category + } + + func debug(_ message: String) { + SpacetimeObservability.logger.log(level: .debug, category: category, message: message) + } + + func info(_ message: String) { + SpacetimeObservability.logger.log(level: .info, category: category, message: message) + } + + func warning(_ message: String) { + SpacetimeObservability.logger.log(level: .warning, category: category, message: message) + } + + func error(_ message: String) { + SpacetimeObservability.logger.log(level: .error, category: category, message: message) + } +} diff --git a/sdks/swift/Sources/SpacetimeDB/Network/ClientMessage.swift b/sdks/swift/Sources/SpacetimeDB/Network/ClientMessage.swift new file mode 100644 index 00000000000..ae5e521d704 --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/Network/ClientMessage.swift @@ -0,0 +1,154 @@ +import Foundation + +// MARK: - Client Messages (Encoding) + +/// Tag order for Rust `websocket::v2::ClientMessage`: +/// 0 = Subscribe +/// 1 = Unsubscribe +/// 2 = OneOffQuery +/// 3 = CallReducer +/// 4 = CallProcedure +public enum ClientMessage: Encodable { + case subscribe(Subscribe) + case unsubscribe(Unsubscribe) + case oneOffQuery(OneOffQuery) + case callReducer(CallReducer) + case callProcedure(CallProcedure) + + public func encode(to encoder: Encoder) throws {} +} + +extension ClientMessage: BSATNSpecialEncodable { + public func encodeBSATN(to storage: inout BSATNStorage) throws { + switch self { + case .subscribe(let msg): + storage.append(0 as UInt8) + try msg.encodeBSATN(to: &storage) + case .unsubscribe(let msg): + storage.append(1 as UInt8) + try msg.encodeBSATN(to: &storage) + case .oneOffQuery(let msg): + storage.append(2 as UInt8) + try msg.encodeBSATN(to: &storage) + case .callReducer(let msg): + storage.append(3 as UInt8) + try msg.encodeBSATN(to: &storage) + case .callProcedure(let msg): + storage.append(4 as UInt8) + try msg.encodeBSATN(to: &storage) + } + } +} + +/// Rust: `Subscribe { request_id: u32, query_set_id: QuerySetId, query_strings: Box<[Box]> }` +public struct Subscribe: Encodable, BSATNSpecialEncodable { + public var requestId: RequestId + public var querySetId: QuerySetId + public var queryStrings: [String] + + public init(queryStrings: [String], requestId: RequestId, querySetId: QuerySetId = QuerySetId(rawValue: 1)) { + self.requestId = requestId + self.querySetId = querySetId + self.queryStrings = queryStrings + } + + public func encode(to encoder: Encoder) throws {} + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + try requestId.encodeBSATN(to: &storage) + try querySetId.encodeBSATN(to: &storage) + storage.append(UInt32(queryStrings.count)) + for query in queryStrings { + try storage.appendString(query) + } + } +} + +/// Rust: `Unsubscribe { request_id: u32, query_set_id: QuerySetId, flags: u8 }` +public struct Unsubscribe: Encodable, BSATNSpecialEncodable { + public var requestId: RequestId + public var querySetId: QuerySetId + public var flags: UInt8 + + public init(requestId: RequestId, querySetId: QuerySetId, flags: UInt8 = 0) { + self.requestId = requestId + self.querySetId = querySetId + self.flags = flags + } + + public func encode(to encoder: Encoder) throws {} + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + try requestId.encodeBSATN(to: &storage) + try querySetId.encodeBSATN(to: &storage) + storage.append(flags) + } +} + +/// Rust: `OneOffQuery { request_id: u32, query_string: Box }` +public struct OneOffQuery: Encodable, BSATNSpecialEncodable { + public var requestId: RequestId + public var queryString: String + + public init(requestId: RequestId, queryString: String) { + self.requestId = requestId + self.queryString = queryString + } + + public func encode(to encoder: Encoder) throws {} + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + try requestId.encodeBSATN(to: &storage) + try storage.appendString(queryString) + } +} + +/// Rust: `CallReducer { request_id: u32, flags: u8, reducer: Box, args: Bytes }` +public struct CallReducer: Encodable, BSATNSpecialEncodable { + public var requestId: RequestId + public var flags: UInt8 + public var reducer: String + public var args: Data + + public init(requestId: RequestId, flags: UInt8, reducer: String, args: Data) { + self.requestId = requestId + self.flags = flags + self.reducer = reducer + self.args = args + } + + public func encode(to encoder: Encoder) throws {} + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + try requestId.encodeBSATN(to: &storage) + storage.append(flags) + try storage.appendString(reducer) + storage.append(UInt32(args.count)) + storage.append(args) + } +} + +/// Rust: `CallProcedure { request_id: u32, flags: u8, procedure: Box, args: Bytes }` +public struct CallProcedure: Encodable, BSATNSpecialEncodable { + public var requestId: RequestId + public var flags: UInt8 + public var procedure: String + public var args: Data + + public init(requestId: RequestId, flags: UInt8, procedure: String, args: Data) { + self.requestId = requestId + self.flags = flags + self.procedure = procedure + self.args = args + } + + public func encode(to encoder: Encoder) throws {} + + public func encodeBSATN(to storage: inout BSATNStorage) throws { + try requestId.encodeBSATN(to: &storage) + storage.append(flags) + try storage.appendString(procedure) + storage.append(UInt32(args.count)) + storage.append(args) + } +} diff --git a/sdks/swift/Sources/SpacetimeDB/Network/NWWebSocketTransport.swift b/sdks/swift/Sources/SpacetimeDB/Network/NWWebSocketTransport.swift new file mode 100644 index 00000000000..a8bd64ebe01 --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/Network/NWWebSocketTransport.swift @@ -0,0 +1,372 @@ +import Foundation +import Network +import Synchronization +import CryptoKit + +protocol WebSocketTransportDelegate: AnyObject, Sendable { + func webSocketTransportDidConnect() + func webSocketTransportDidDisconnect(error: Error?) + func webSocketTransportDidReceive(data: Data) + func webSocketTransportDidReceivePong() +} + +protocol WebSocketTransport: AnyObject, Sendable { + var delegate: WebSocketTransportDelegate? { get set } + func connect(to url: URL, protocols: [String], headers: [String: String]) + func disconnect() + func send(data: Data, completion: @escaping @Sendable (Error?) -> Void) + func sendPing(completion: @escaping @Sendable (Error?) -> Void) +} + +final class NWWebSocketTransport: WebSocketTransport, @unchecked Sendable { + private let stateLock: Mutex = Mutex(()) + private var connection: NWConnection? + private weak var _delegate: WebSocketTransportDelegate? + private var closingConnection = false + private var didCompleteHandshake = false + private var handshakeKey = "" + private var incomingBuffer = Data() + private let queue = DispatchQueue(label: "spacetimedb.transport.nw", qos: .userInitiated) + + var delegate: WebSocketTransportDelegate? { + get { stateLock.withLock { _ in _delegate } } + set { stateLock.withLock { _ in _delegate = newValue } } + } + + func connect(to url: URL, protocols: [String], headers: [String: String]) { + guard let host = url.host else { + delegate?.webSocketTransportDidDisconnect(error: NSError(domain: "NWWebSocketTransport", code: 0, userInfo: [NSLocalizedDescriptionKey: "Missing host in URL"])) + return + } + let isSecure = (url.scheme == "wss" || url.scheme == "https") + let port = NWEndpoint.Port(integerLiteral: NWEndpoint.Port.IntegerLiteralType(url.port ?? (isSecure ? 443 : 80))) + let tcp = NWProtocolTCP.Options() + let parameters = NWParameters(tls: isSecure ? NWProtocolTLS.Options() : nil, tcp: tcp) + let connection = NWConnection(host: NWEndpoint.Host(host), port: port, using: parameters) + + self.stateLock.withLock { _ in + self.closingConnection = false + self.didCompleteHandshake = false + self.incomingBuffer.removeAll(keepingCapacity: true) + self.handshakeKey = Self.makeSecWebSocketKey() + self.connection?.cancel() + self.connection = connection + } + + connection.stateUpdateHandler = { [weak self] state in + self?.handleStateChange( + state, + url: url, + protocols: protocols, + headers: headers + ) + } + + connection.start(queue: queue) + } + + private func handleStateChange( + _ state: NWConnection.State, + url: URL, + protocols: [String], + headers: [String: String] + ) { + switch state { + case .ready: + sendHandshake(url: url, protocols: protocols, headers: headers) + case .failed(let error): + stateLock.withLock { _ in connection = nil } + delegate?.webSocketTransportDidDisconnect(error: error) + case .cancelled: + let shouldNotify = stateLock.withLock { _ in + let notify = !closingConnection + connection = nil + closingConnection = false + return notify + } + if shouldNotify { + delegate?.webSocketTransportDidDisconnect(error: nil) + } + case .waiting(let error): + Log.network.debug("NWConnection waiting: \(error.localizedDescription)") + case .preparing, .setup: + break + @unknown default: + break + } + } + + private func sendHandshake(url: URL, protocols: [String], headers: [String: String]) { + guard let connection = stateLock.withLock({ _ in self.connection }) else { + delegate?.webSocketTransportDidDisconnect(error: NSError(domain: "NWWebSocketTransport", code: 0, userInfo: [NSLocalizedDescriptionKey: "Connection not available"])) + return + } + let key = stateLock.withLock { _ in handshakeKey } + let request = makeHandshakeRequest(url: url, key: key, protocols: protocols, headers: headers) + connection.send(content: request, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.delegate?.webSocketTransportDidDisconnect(error: error) + return + } + self.receiveHandshakeResponse() + }) + } + + private func makeHandshakeRequest(url: URL, key: String, protocols: [String], headers: [String: String]) -> Data { + let path = (url.path.isEmpty ? "/" : url.path) + (url.query.map { "?\($0)" } ?? "") + let isSecure = (url.scheme == "wss" || url.scheme == "https") + let defaultPort = isSecure ? 443 : 80 + let host = url.host ?? "localhost" + let hostHeader: String + if let port = url.port, port != defaultPort { + hostHeader = "\(host):\(port)" + } else { + hostHeader = host + } + + var lines = [ + "GET \(path) HTTP/1.1", + "Host: \(hostHeader)", + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Version: 13", + "Sec-WebSocket-Key: \(key)" + ] + if !protocols.isEmpty { + lines.append("Sec-WebSocket-Protocol: \(protocols.joined(separator: ", "))") + } + for (name, value) in headers where name.caseInsensitiveCompare("Host") != .orderedSame { + lines.append("\(name): \(value)") + } + let request = lines.joined(separator: "\r\n") + "\r\n\r\n" + return Data(request.utf8) + } + + private func receiveHandshakeResponse() { + guard let connection = stateLock.withLock({ _ in self.connection }) else { return } + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, error in + guard let self else { return } + if let error { + self.delegate?.webSocketTransportDidDisconnect(error: error) + return + } + if isComplete, content == nil { + self.delegate?.webSocketTransportDidDisconnect(error: nil) + return + } + + if let content { + let done = self.stateLock.withLock { _ -> Bool in + self.incomingBuffer.append(content) + guard let range = self.incomingBuffer.range(of: Data("\r\n\r\n".utf8)) else { + return false + } + let headerData = self.incomingBuffer[.. Bool { + guard let headerString = String(data: Data(headerData), encoding: .utf8) else { return false } + let lines = headerString.split(separator: "\r\n", omittingEmptySubsequences: false).map(String.init) + guard let statusLine = lines.first, statusLine.contains(" 101 ") else { return false } + var acceptValue: String? + for line in lines.dropFirst() { + guard let sep = line.firstIndex(of: ":") else { continue } + let name = line[.. Void) { + let connection = stateLock.withLock { _ in self.connection } + guard let connection = connection else { + completion(NSError(domain: "NWWebSocketTransport", code: 0, userInfo: [NSLocalizedDescriptionKey: "Not connected"])) + return + } + + let frame = Self.makeFrame(opcode: 0x2, payload: data) + connection.send(content: frame, completion: .contentProcessed { error in + completion(error) + }) + } + + func sendPing(completion: @escaping @Sendable (Error?) -> Void) { + let connection = stateLock.withLock { _ in self.connection } + guard let connection = connection else { + completion(NSError(domain: "NWWebSocketTransport", code: 0, userInfo: [NSLocalizedDescriptionKey: "Not connected"])) + return + } + + let frame = Self.makeFrame(opcode: 0x9, payload: Data()) + connection.send(content: frame, completion: .contentProcessed { error in + completion(error) + }) + } + + private func receiveNextMessage() { + let connection = stateLock.withLock { _ in self.connection } + guard let connection = connection else { return } + + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, error in + guard let self = self else { return } + + if let error { + self.delegate?.webSocketTransportDidDisconnect(error: error) + return + } + if isComplete, content == nil { + self.delegate?.webSocketTransportDidDisconnect(error: nil) + return + } + + if let content, !content.isEmpty { + var frames: [(UInt8, Data)] = [] + self.stateLock.withLock { _ in + self.incomingBuffer.append(content) + frames = Self.parseFrames(from: &self.incomingBuffer) + } + for (opcode, payload) in frames { + switch opcode { + case 0x2: + self.delegate?.webSocketTransportDidReceive(data: payload) + case 0x9: + let pong = Self.makeFrame(opcode: 0xA, payload: payload) + connection.send(content: pong, completion: .contentProcessed { _ in }) + case 0xA: + self.delegate?.webSocketTransportDidReceivePong() + case 0x8: + self.disconnect() + default: + break + } + } + } + + self.receiveNextMessage() + } + } + + private static func makeSecWebSocketKey() -> String { + var bytes = [UInt8](repeating: 0, count: 16) + for i in bytes.indices { + bytes[i] = UInt8.random(in: 0...255) + } + return Data(bytes).base64EncodedString() + } + + private static func computeAcceptKey(from key: String) -> String { + let magic = key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + let digest = Insecure.SHA1.hash(data: Data(magic.utf8)) + return Data(digest).base64EncodedString() + } + + private static func makeFrame(opcode: UInt8, payload: Data) -> Data { + var frame = Data() + frame.append(0x80 | (opcode & 0x0F)) + let maskBit: UInt8 = 0x80 + let length = payload.count + if length <= 125 { + frame.append(maskBit | UInt8(length)) + } else if length <= 0xFFFF { + frame.append(maskBit | 126) + frame.append(UInt8((length >> 8) & 0xFF)) + frame.append(UInt8(length & 0xFF)) + } else { + frame.append(maskBit | 127) + let l = UInt64(length) + for shift in stride(from: 56, through: 0, by: -8) { + frame.append(UInt8((l >> UInt64(shift)) & 0xFF)) + } + } + let mask: [UInt8] = (0..<4).map { _ in UInt8.random(in: 0...255) } + frame.append(contentsOf: mask) + for (index, byte) in payload.enumerated() { + frame.append(byte ^ mask[index % 4]) + } + return frame + } + + private static func parseFrames(from buffer: inout Data) -> [(UInt8, Data)] { + var frames: [(UInt8, Data)] = [] + var index = 0 + while true { + let start = index + guard buffer.count - index >= 2 else { break } + let first = buffer[index] + let second = buffer[index + 1] + index += 2 + + let opcode = first & 0x0F + let masked = (second & 0x80) != 0 + var payloadLength = Int(second & 0x7F) + if payloadLength == 126 { + guard buffer.count - index >= 2 else { index = start; break } + payloadLength = Int(buffer[index]) << 8 | Int(buffer[index + 1]) + index += 2 + } else if payloadLength == 127 { + guard buffer.count - index >= 8 else { index = start; break } + var len: UInt64 = 0 + for b in buffer[index..<(index + 8)] { + len = (len << 8) | UInt64(b) + } + guard len <= UInt64(Int.max) else { index = start; break } + payloadLength = Int(len) + index += 8 + } + + var maskingKey: [UInt8] = [0, 0, 0, 0] + if masked { + guard buffer.count - index >= 4 else { index = start; break } + maskingKey = Array(buffer[index..<(index + 4)]) + index += 4 + } + guard buffer.count - index >= payloadLength else { index = start; break } + + var payload = Data(buffer[index..<(index + payloadLength)]) + index += payloadLength + if masked { + let payloadCount = payload.count + payload.withUnsafeMutableBytes { bytes in + guard let ptr = bytes.bindMemory(to: UInt8.self).baseAddress else { return } + for i in 0.. 0 { + buffer.removeSubrange(0.. Void)? + } + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "spacetimedb.network-monitor", qos: .utility) + private let stateLock: Mutex = Mutex(State()) + + var isConnected: Bool { + stateLock.withLock { state in + state.isConnected + } + } + + var onPathChange: (@Sendable (Bool) -> Void)? { + get { + stateLock.withLock { state in + state.onPathChange + } + } + set { + stateLock.withLock { state in + state.onPathChange = newValue + } + } + } + + func start() { + monitor.pathUpdateHandler = { [weak self] path in + let satisfied = path.status == .satisfied + guard let self else { return } + let callback = self.stateLock.withLock { state -> (@Sendable (Bool) -> Void)? in + let changed = state.isConnected != satisfied + guard changed else { return nil } + state.isConnected = satisfied + return state.onPathChange + } + + if let callback { + Log.network.info("Network path changed: \(satisfied ? "connected" : "disconnected")") + callback(satisfied) + } + } + monitor.start(queue: queue) + } + + func stop() { + monitor.cancel() + } +} diff --git a/sdks/swift/Sources/SpacetimeDB/Network/ProtocolTypes.swift b/sdks/swift/Sources/SpacetimeDB/Network/ProtocolTypes.swift new file mode 100644 index 00000000000..ea151bb7b64 --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/Network/ProtocolTypes.swift @@ -0,0 +1,234 @@ +import Foundation + +// MARK: - Shared Protocol Types + +public struct SingleTableRows: BSATNSpecialDecodable, Sendable, Decodable { + public var table: RawIdentifier + public var rows: BsatnRowList + + public init(table: RawIdentifier, rows: BsatnRowList) { + self.table = table + self.rows = rows + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> SingleTableRows { + return SingleTableRows( + table: try RawIdentifier.decodeBSATN(from: &reader), + rows: try BsatnRowList.decodeBSATN(from: &reader) + ) + } +} + +public struct QueryRows: BSATNSpecialDecodable, Sendable, Decodable { + public var tables: [SingleTableRows] + + public init(tables: [SingleTableRows]) { + self.tables = tables + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> QueryRows { + return QueryRows( + tables: try reader.readArray { reader in try SingleTableRows.decodeBSATN(from: &reader) } + ) + } +} + +public struct TransactionUpdate: BSATNSpecialDecodable, Sendable, Decodable { + public var querySets: [QuerySetUpdate] + + public init(querySets: [QuerySetUpdate]) { + self.querySets = querySets + } + + public init(from decoder: Decoder) throws { + fatalError("Handled by BSATNSpecialDecodable") + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> TransactionUpdate { + return TransactionUpdate( + querySets: try reader.readArray { reader in try QuerySetUpdate.decodeBSATN(from: &reader) } + ) + } +} + +public struct QuerySetUpdate: BSATNSpecialDecodable, Sendable, Decodable { + public var querySetId: QuerySetId + public var tables: [TableUpdate] + + public init(querySetId: QuerySetId, tables: [TableUpdate]) { + self.querySetId = querySetId + self.tables = tables + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> QuerySetUpdate { + return QuerySetUpdate( + querySetId: try QuerySetId.decodeBSATN(from: &reader), + tables: try reader.readArray { reader in try TableUpdate.decodeBSATN(from: &reader) } + ) + } +} + +public struct TableUpdate: Sendable, BSATNSpecialDecodable, Decodable { + public var tableName: RawIdentifier + public var rows: [TableUpdateRows] + + public init(tableName: RawIdentifier, rows: [TableUpdateRows]) { + self.tableName = tableName + self.rows = rows + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> TableUpdate { + return TableUpdate( + tableName: try RawIdentifier.decodeBSATN(from: &reader), + rows: try reader.readArray { reader in try TableUpdateRows.decodeBSATN(from: &reader) } + ) + } +} + +public enum TableUpdateRows: Sendable, Decodable, BSATNSpecialDecodable { + case persistentTable(PersistentTableRows) + case eventTable(EventTableRows) + + public init(from decoder: Decoder) throws { + fatalError("Use BSATNSpecialDecodable") + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> TableUpdateRows { + try reader.readTaggedEnum { reader, tag in + switch tag { + case 0: return .persistentTable(try PersistentTableRows.decodeBSATN(from: &reader)) + case 1: return .eventTable(try EventTableRows.decodeBSATN(from: &reader)) + default: throw BSATNDecodingError.unsupportedType + } + } + } +} + +public struct PersistentTableRows: Sendable, BSATNSpecialDecodable, Decodable { + public var inserts: BsatnRowList + public var deletes: BsatnRowList + + public init(inserts: BsatnRowList, deletes: BsatnRowList) { + self.inserts = inserts + self.deletes = deletes + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> PersistentTableRows { + return PersistentTableRows( + inserts: try BsatnRowList.decodeBSATN(from: &reader), + deletes: try BsatnRowList.decodeBSATN(from: &reader) + ) + } +} + +public struct EventTableRows: Sendable, BSATNSpecialDecodable, Decodable { + public var events: BsatnRowList + + public init(events: BsatnRowList) { + self.events = events + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> EventTableRows { + return EventTableRows( + events: try BsatnRowList.decodeBSATN(from: &reader) + ) + } +} + +public enum RowSizeHint: BSATNSpecialDecodable, Sendable, Decodable { + case fixedSize(UInt16) + case rowOffsets([UInt64]) + + public init(from decoder: Decoder) throws { + fatalError("Use BSATNSpecialDecodable") + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> RowSizeHint { + try reader.readTaggedEnum { reader, tag in + switch tag { + case 0: return .fixedSize(try reader.read(UInt16.self)) + case 1: return .rowOffsets(try reader.readArray { reader in try reader.read(UInt64.self) }) + default: throw BSATNDecodingError.unsupportedType + } + } + } +} + +public struct BsatnRowList: BSATNSpecialDecodable, Sendable, Decodable { + public static let empty = BsatnRowList(sizeHint: .rowOffsets([]), rowsData: Data()) + + public var sizeHint: RowSizeHint + public var rowsData: Data + + init(sizeHint: RowSizeHint, rowsData: Data) { + self.sizeHint = sizeHint + self.rowsData = rowsData + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> BsatnRowList { + let sizeHint = try RowSizeHint.decodeBSATN(from: &reader) + let dataLen = try reader.read(UInt32.self) + let rowsData = try reader.readBytes(count: Int(dataLen)) + return BsatnRowList(sizeHint: sizeHint, rowsData: rowsData) + } +} + +public enum ReducerOutcome: Decodable, BSATNSpecialDecodable, Sendable { + case ok(ReducerOk) + case okEmpty + case err(Data) + case internalError(String) + + public init(from decoder: Decoder) throws { + fatalError("Use BSATNSpecialDecodable") + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> ReducerOutcome { + try reader.readTaggedEnum { reader, tag in + switch tag { + case 0: return .ok(try ReducerOk.decodeBSATN(from: &reader)) + case 1: return .okEmpty + case 2: + let len = try reader.read(UInt32.self) + return .err(try reader.readBytes(count: Int(len))) + case 3: + return .internalError(try reader.readString()) + default: throw BSATNDecodingError.unsupportedType + } + } + } +} + +public struct ReducerOk: BSATNSpecialDecodable, Decodable, Sendable { + public var retValue: Data + public var transactionUpdate: TransactionUpdate + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> ReducerOk { + let len = try reader.read(UInt32.self) + let retValue = try reader.readBytes(count: Int(len)) + let transactionUpdate = try TransactionUpdate.decodeBSATN(from: &reader) + return ReducerOk(retValue: retValue, transactionUpdate: transactionUpdate) + } +} + +public enum ProcedureStatus: Decodable, BSATNSpecialDecodable, Sendable { + case returned(Data) + case internalError(String) + + public init(from decoder: Decoder) throws { + fatalError("Use BSATNSpecialDecodable") + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> ProcedureStatus { + try reader.readTaggedEnum { reader, tag in + switch tag { + case 0: + let len = try reader.read(UInt32.self) + return .returned(try reader.readBytes(count: Int(len))) + case 1: + return .internalError(try reader.readString()) + default: throw BSATNDecodingError.unsupportedType + } + } + } +} diff --git a/sdks/swift/Sources/SpacetimeDB/Network/ServerMessage.swift b/sdks/swift/Sources/SpacetimeDB/Network/ServerMessage.swift new file mode 100644 index 00000000000..7e79d1cbcb4 --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/Network/ServerMessage.swift @@ -0,0 +1,225 @@ +import Foundation + +// MARK: - Server Messages (Decoding) + +/// Tag order for Rust `websocket::v2::ServerMessage`: +/// 0 = InitialConnection +/// 1 = SubscribeApplied +/// 2 = UnsubscribeApplied +/// 3 = SubscriptionError +/// 4 = TransactionUpdate +/// 5 = OneOffQueryResult +/// 6 = ReducerResult +/// 7 = ProcedureResult +public enum ServerMessage: Decodable, BSATNSpecialDecodable, Sendable { + case initialConnection(InitialConnection) + case subscribeApplied(SubscribeApplied) + case unsubscribeApplied(UnsubscribeApplied) + case subscriptionError(SubscriptionError) + case transactionUpdate(TransactionUpdate) + case oneOffQueryResult(OneOffQueryResult) + case reducerResult(ReducerResult) + case procedureResult(ProcedureResult) + case other(UInt8) + + public init(from decoder: Decoder) throws { + fatalError("Use BSATNDecoder for ServerMessage") + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> ServerMessage { + try reader.readTaggedEnum { reader, tag in + switch tag { + case 0: return .initialConnection(try InitialConnection.decodeBSATN(from: &reader)) + case 1: return .subscribeApplied(try SubscribeApplied.decodeBSATN(from: &reader)) + case 2: return .unsubscribeApplied(try UnsubscribeApplied.decodeBSATN(from: &reader)) + case 3: return .subscriptionError(try SubscriptionError.decodeBSATN(from: &reader)) + case 4: return .transactionUpdate(try TransactionUpdate.decodeBSATN(from: &reader)) + case 5: return .oneOffQueryResult(try OneOffQueryResult.decodeBSATN(from: &reader)) + case 6: return .reducerResult(try ReducerResult.decodeBSATN(from: &reader)) + case 7: return .procedureResult(try ProcedureResult.decodeBSATN(from: &reader)) + default: + _ = try? reader.readBytes(count: reader.remaining) + return .other(tag) + } + } + } +} + +// MARK: - Connection / Subscription + +/// Rust: `InitialConnection { identity: Identity, connection_id: ConnectionId, token: Box }` +public struct InitialConnection: BSATNSpecialDecodable, Decodable, Sendable { + public var identity: Identity + public var connectionId: ClientConnectionId + public var token: String + + public init(identity: Identity, connectionId: ClientConnectionId, token: String) { + self.identity = identity + self.connectionId = connectionId + self.token = token + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> InitialConnection { + let identity = try Identity.decodeBSATN(from: &reader) + let connectionId = try ClientConnectionId.decodeBSATN(from: &reader) + let token = try reader.readString() + return InitialConnection(identity: identity, connectionId: connectionId, token: token) + } +} + +/// Rust: `SubscribeApplied { request_id: u32, query_set_id: QuerySetId, rows: QueryRows }` +public struct SubscribeApplied: BSATNSpecialDecodable, Decodable, Sendable { + public var requestId: RequestId + public var querySetId: QuerySetId + public var rows: QueryRows + + public init(requestId: RequestId, querySetId: QuerySetId, rows: QueryRows) { + self.requestId = requestId + self.querySetId = querySetId + self.rows = rows + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> SubscribeApplied { + return SubscribeApplied( + requestId: try RequestId.decodeBSATN(from: &reader), + querySetId: try QuerySetId.decodeBSATN(from: &reader), + rows: try QueryRows.decodeBSATN(from: &reader) + ) + } + + func asTransactionUpdate() -> TransactionUpdate { + let tables = rows.tables.map { + TableUpdate( + tableName: $0.table, + rows: [.persistentTable(PersistentTableRows( + inserts: $0.rows, + deletes: BsatnRowList.empty + ))] + ) + } + return TransactionUpdate(querySets: [QuerySetUpdate(querySetId: querySetId, tables: tables)]) + } +} + +/// Rust: `UnsubscribeApplied { request_id: u32, query_set_id: QuerySetId, rows: Option }` +public struct UnsubscribeApplied: BSATNSpecialDecodable, Decodable, Sendable { + public var requestId: RequestId + public var querySetId: QuerySetId + public var rows: QueryRows? + + public init(requestId: RequestId, querySetId: QuerySetId, rows: QueryRows?) { + self.requestId = requestId + self.querySetId = querySetId + self.rows = rows + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> UnsubscribeApplied { + return UnsubscribeApplied( + requestId: try RequestId.decodeBSATN(from: &reader), + querySetId: try QuerySetId.decodeBSATN(from: &reader), + rows: try Optional.decodeBSATN(from: &reader) + ) + } +} + +/// Rust: `SubscriptionError { request_id: Option, query_set_id: QuerySetId, error: Box }` +public struct SubscriptionError: BSATNSpecialDecodable, Decodable, Sendable { + public var requestId: RequestId? + public var querySetId: QuerySetId + public var error: String + + public init(requestId: RequestId?, querySetId: QuerySetId, error: String) { + self.requestId = requestId + self.querySetId = querySetId + self.error = error + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> SubscriptionError { + return SubscriptionError( + requestId: try Optional.decodeBSATN(from: &reader), + querySetId: try QuerySetId.decodeBSATN(from: &reader), + error: try reader.readString() + ) + } +} + +// MARK: - Query / Reducer / Procedure Results + +public struct OneOffQueryResult: BSATNSpecialDecodable, Decodable, Sendable { + public var requestId: RequestId + public var result: QueryRowsResult + + public init(requestId: RequestId, result: QueryRowsResult) { + self.requestId = requestId + self.result = result + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> OneOffQueryResult { + return OneOffQueryResult( + requestId: try RequestId.decodeBSATN(from: &reader), + result: try QueryRowsResult.decodeBSATN(from: &reader) + ) + } +} + +public enum QueryRowsResult: Decodable, BSATNSpecialDecodable, Sendable { + case ok(QueryRows) + case err(String) + + public init(from decoder: Decoder) throws { + fatalError("Use BSATNSpecialDecodable") + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> QueryRowsResult { + try reader.readTaggedEnum { reader, tag in + switch tag { + case 0: return .ok(try QueryRows.decodeBSATN(from: &reader)) + case 1: return .err(try reader.readString()) + default: throw BSATNDecodingError.unsupportedType + } + } + } +} + +public struct ReducerResult: BSATNSpecialDecodable, Decodable, Sendable { + public var requestId: RequestId + public var timestamp: Int64 + public var result: ReducerOutcome + + public init(requestId: RequestId, timestamp: Int64, result: ReducerOutcome) { + self.requestId = requestId + self.timestamp = timestamp + self.result = result + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> ReducerResult { + return ReducerResult( + requestId: try RequestId.decodeBSATN(from: &reader), + timestamp: try reader.read(Int64.self), + result: try ReducerOutcome.decodeBSATN(from: &reader) + ) + } +} + +public struct ProcedureResult: BSATNSpecialDecodable, Decodable, Sendable { + public var status: ProcedureStatus + public var timestamp: Int64 + public var totalHostExecutionDuration: Int64 + public var requestId: RequestId + + public init(status: ProcedureStatus, timestamp: Int64, totalHostExecutionDuration: Int64, requestId: RequestId) { + self.status = status + self.timestamp = timestamp + self.totalHostExecutionDuration = totalHostExecutionDuration + self.requestId = requestId + } + + public static func decodeBSATN(from reader: inout BSATNReader) throws -> ProcedureResult { + return ProcedureResult( + status: try ProcedureStatus.decodeBSATN(from: &reader), + timestamp: try reader.read(Int64.self), + totalHostExecutionDuration: try reader.read(Int64.self), + requestId: try RequestId.decodeBSATN(from: &reader) + ) + } +} diff --git a/sdks/swift/Sources/SpacetimeDB/Network/ServerMessageFrameDecoder.swift b/sdks/swift/Sources/SpacetimeDB/Network/ServerMessageFrameDecoder.swift new file mode 100644 index 00000000000..bce1a1f4eee --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/Network/ServerMessageFrameDecoder.swift @@ -0,0 +1,125 @@ +import Compression +import Foundation + +enum ServerMessageFrameDecodingError: Error { + case emptyFrame + case unsupportedCompression(UInt8) + case invalidInputSize + case initializationFailed + case decompressionFailed +} + +enum ServerMessageFrameDecoder { + private static let compressionTagNone: UInt8 = 0 + private static let compressionTagBrotli: UInt8 = 1 + private static let compressionTagGzip: UInt8 = 2 + + static func decodePayload(from frame: Data) throws -> Data { + guard let compressionTag = frame.first else { + throw ServerMessageFrameDecodingError.emptyFrame + } + + let payload = Data(frame.dropFirst()) + switch compressionTag { + case compressionTagNone: + return payload + case compressionTagBrotli: + return try decompress(payload, algorithm: COMPRESSION_BROTLI) + case compressionTagGzip: + return try decompressGzip(payload) + default: + throw ServerMessageFrameDecodingError.unsupportedCompression(compressionTag) + } + } + + private static func decompress(_ payload: Data, algorithm: compression_algorithm) throws -> Data { + if payload.isEmpty { + return Data() + } + + let destinationBufferSize = 64 * 1024 + let bootstrapPtr = UnsafeMutablePointer.allocate(capacity: 1) + defer { bootstrapPtr.deallocate() } + var stream = compression_stream( + dst_ptr: bootstrapPtr, + dst_size: 0, + src_ptr: UnsafePointer(bootstrapPtr), + src_size: 0, + state: nil + ) + let initStatus = compression_stream_init(&stream, COMPRESSION_STREAM_DECODE, algorithm) + guard initStatus != COMPRESSION_STATUS_ERROR else { + throw ServerMessageFrameDecodingError.initializationFailed + } + defer { compression_stream_destroy(&stream) } + + return try payload.withUnsafeBytes { rawBuffer in + guard let srcBase = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { + return Data() + } + + stream.src_ptr = srcBase + stream.src_size = payload.count + + let destinationBuffer = UnsafeMutablePointer.allocate(capacity: destinationBufferSize) + defer { destinationBuffer.deallocate() } + + var output = Data() + while true { + stream.dst_ptr = destinationBuffer + stream.dst_size = destinationBufferSize + + let status = compression_stream_process(&stream, Int32(COMPRESSION_STREAM_FINALIZE.rawValue)) + let produced = destinationBufferSize - stream.dst_size + if produced > 0 { + output.append(destinationBuffer, count: produced) + } + + switch status { + case COMPRESSION_STATUS_OK: + continue + case COMPRESSION_STATUS_END: + return output + default: + throw ServerMessageFrameDecodingError.decompressionFailed + } + } + } + } + + private static func decompressGzip(_ payload: Data) throws -> Data { + if payload.isEmpty { + return Data() + } + + guard payload.count >= 10 else { + throw ServerMessageFrameDecodingError.invalidInputSize + } + + var offset = 10 + let flags = payload[3] + if flags & 0x04 != 0 { + guard offset + 2 <= payload.count else { throw ServerMessageFrameDecodingError.invalidInputSize } + let xlen = Int(payload[offset]) | (Int(payload[offset+1]) << 8) + offset += 2 + xlen + } + if flags & 0x08 != 0 { + while offset < payload.count && payload[offset] != 0 { offset += 1 } + offset += 1 + } + if flags & 0x10 != 0 { + while offset < payload.count && payload[offset] != 0 { offset += 1 } + offset += 1 + } + if flags & 0x02 != 0 { + offset += 2 + } + + guard offset <= payload.count - 8 else { + throw ServerMessageFrameDecodingError.invalidInputSize + } + + let deflateData = payload[offset ..< payload.count - 8] + return try decompress(Data(deflateData), algorithm: COMPRESSION_ZLIB) + } +} diff --git a/sdks/swift/Sources/SpacetimeDB/Network/SpacetimeClient.swift b/sdks/swift/Sources/SpacetimeDB/Network/SpacetimeClient.swift new file mode 100644 index 00000000000..2bdd8e7a16c --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/Network/SpacetimeClient.swift @@ -0,0 +1,1413 @@ +import Foundation +import Network +import Synchronization + +public enum SpacetimeClientProcedureError: Error, Equatable { + case internalError(String) + case disconnected + case timeout +} + +public enum SpacetimeClientConnectionError: Error, Equatable { + case keepAliveTimeout +} + +private final class AsyncResponseContinuation: @unchecked Sendable { + private let stateLock: Mutex = Mutex(()) + private var continuation: CheckedContinuation? + private var timeoutTask: Task? + private var completionResult: Result? + private var isCompleted = false + + @inline(__always) + private func withStateLock(_ body: () throws -> R) rethrows -> R { + try stateLock.withLock { _ in + try body() + } + } + + func install(_ continuation: CheckedContinuation) { + var resultToResume: Result? + + withStateLock { + if isCompleted { + resultToResume = completionResult + completionResult = nil + } else { + self.continuation = continuation + } + } + + if let resultToResume { + resume(continuation, with: resultToResume) + } + } + + func setTimeoutTask(_ task: Task) { + var shouldCancelTask = false + + withStateLock { + if isCompleted { + shouldCancelTask = true + } else { + timeoutTask = task + } + } + + if shouldCancelTask { + task.cancel() + } + } + + func resolve(_ result: Result) { + var continuationToResume: CheckedContinuation? + var timeoutTaskToCancel: Task? + + let didResolve = withStateLock { () -> Bool in + if isCompleted { + return false + } + isCompleted = true + timeoutTaskToCancel = timeoutTask + timeoutTask = nil + + if let continuation { + continuationToResume = continuation + self.continuation = nil + } else { + completionResult = result + } + return true + } + guard didResolve else { return } + + timeoutTaskToCancel?.cancel() + if let continuationToResume { + resume(continuationToResume, with: result) + } + } + + private func resume(_ continuation: CheckedContinuation, with result: Result) { + switch result { + case .success(let value): + continuation.resume(returning: value) + case .failure(let error): + continuation.resume(throwing: error) + } + } +} + +@MainActor +public protocol SpacetimeClientDelegate: AnyObject { + func onConnect() + func onDisconnect(error: Error?) + func onConnectError(error: Error) + func onConnectionStateChange(state: ConnectionState) + func onIdentityReceived(identity: [UInt8], token: String) + func onTransactionUpdate(message: Data?) + func onReducerError(reducer: String, message: String, isInternal: Bool) +} + +public extension SpacetimeClientDelegate { + func onConnectError(error: Error) {} + func onConnectionStateChange(state: ConnectionState) {} + func onReducerError(reducer: String, message: String, isInternal: Bool) {} +} + +public final class SpacetimeClient: @unchecked Sendable, WebSocketTransportDelegate { + private struct UnsafeSendableCallback: @unchecked Sendable { + var value: (@Sendable () -> Void)? + } + private struct UnsafeSendableCountCallback: @unchecked Sendable { + var value: (@Sendable (UInt64) -> Void)? + } + + public let serverUrl: URL + public let moduleName: String + + private let stateLock: Mutex = Mutex(()) + @inline(__always) + private func withStateLock(_ body: () throws -> R) rethrows -> R { + try stateLock.withLock { _ in + try body() + } + } + + private let callbackStateLock: Mutex = Mutex(()) + @inline(__always) + private func withCallbackStateLock(_ body: () throws -> R) rethrows -> R { + try callbackStateLock.withLock { _ in + try body() + } + } + + private let idStateLock: Mutex = Mutex(()) + @inline(__always) + private func withIdStateLock(_ body: () throws -> R) rethrows -> R { + try idStateLock.withLock { _ in + try body() + } + } + + private weak var _delegate: SpacetimeClientDelegate? + public var delegate: SpacetimeClientDelegate? { + get { withCallbackStateLock { _delegate } } + set { withCallbackStateLock { _delegate = newValue } } + } + + private var _connectionState: ConnectionState = .disconnected + public var connectionState: ConnectionState { + withStateLock { _connectionState } + } + + private static let sharedStateLock: Mutex = Mutex(()) + @inline(__always) + private static func withSharedStateLock(_ body: () throws -> R) rethrows -> R { + try sharedStateLock.withLock { _ in + try body() + } + } + nonisolated(unsafe) private static var _shared: SpacetimeClient? + public static var shared: SpacetimeClient? { + get { withSharedStateLock { _shared } } + set { withSharedStateLock { _shared = newValue } } + } + + nonisolated(unsafe) public static var clientCache = ClientCache() + + private let transport: WebSocketTransport + private let reconnectPolicy: ReconnectPolicy? + private let compressionMode: CompressionMode + private var savedToken: String? + private var reconnectAttempt = 0 + private var shouldStayConnected = false + + private let encoder = BSATNEncoder() + private let decoder = BSATNDecoder() + private var nextRequestId = RequestId(rawValue: 1) + private var nextQuerySetId = QuerySetId(rawValue: 1) + private var pendingReducerNames: [RequestId: String] = [:] + private var pendingProcedureCallbacks: [RequestId: (Result) -> Void] = [:] + private var pendingOneOffQueryCallbacks: [RequestId: (Result) -> Void] = [:] + private var pendingSubscriptionByRequestId: [RequestId: SubscriptionHandle] = [:] + private var activeSubscriptionByQuerySetId: [QuerySetId: SubscriptionHandle] = [:] + private var pendingUnsubscribeByRequestId: [RequestId: SubscriptionHandle] = [:] + private var managedSubscriptions: [ObjectIdentifier: SubscriptionHandle] = [:] + private var rawTransactionUpdateObserver = UnsafeSendableCallback(value: nil) + private var rawTransactionUpdateCountObserver = UnsafeSendableCountCallback(value: nil) + private var pendingRawTransactionUpdateCount: UInt64 = 0 + private var rawTransactionUpdateCountDrainScheduled = false + private let decodeQueue = DispatchQueue(label: "spacetimedb.client.decode", qos: .utility) + + // Send queue — NWWebSocketTransport handles its own internal queuing but we maintain + // a simplified queue to preserve ordering and handle connection lifecycle. + private var sendQueue: [Data] = [] + private var isTransportReady = false + private var networkMonitor: NetworkMonitor? + private let keepAlivePingInterval: Duration + private let keepAlivePongTimeout: Duration + private var keepAliveTask: Task? + private var keepAliveTimeoutTask: Task? + private var awaitingKeepAlivePong = false + private var reconnectTask: Task? + private var isHandlingConnectionFailure = false + private var transactionUpdateCallbackScheduled = false + private var transactionUpdateCallbackDirty = false + private let coalesceTransactionUpdateCallbacks: Bool + + public init( + serverUrl: URL, + moduleName: String, + reconnectPolicy: ReconnectPolicy? = ReconnectPolicy(), + compressionMode: CompressionMode = .gzip, + coalesceTransactionUpdateCallbacks: Bool = true, + keepAlivePingIntervalSeconds: TimeInterval = 30.0, + keepAlivePongTimeoutSeconds: TimeInterval = 10.0 + ) { + self.serverUrl = serverUrl + self.moduleName = moduleName + self.reconnectPolicy = reconnectPolicy + self.compressionMode = compressionMode + self.coalesceTransactionUpdateCallbacks = coalesceTransactionUpdateCallbacks + let boundedPingInterval = max(1.0, keepAlivePingIntervalSeconds) + let boundedPongTimeout = max(1.0, min(keepAlivePongTimeoutSeconds, boundedPingInterval)) + self.keepAlivePingInterval = .milliseconds(Int64((boundedPingInterval * 1000).rounded())) + self.keepAlivePongTimeout = .milliseconds(Int64((boundedPongTimeout * 1000).rounded())) + + self.transport = NWWebSocketTransport() + self.transport.delegate = self + } + + public func connect(token: String? = nil) { + withStateLock { + shouldStayConnected = true + reconnectAttempt = 0 + if let token { + self.savedToken = token + } + reconnectTask?.cancel() + reconnectTask = nil + } + + startNetworkMonitor() + performConnect(authToken: token ?? getSavedToken(), isReconnect: false) + } + + /// Registers an optional callback fired synchronously after transaction + /// updates are applied to the local cache. + /// + /// The callback may execute on internal non-main queues. + public func setRawTransactionUpdateObserver(_ observer: (@Sendable () -> Void)?) { + withCallbackStateLock { + rawTransactionUpdateObserver.value = observer + } + } + + /// Registers an optional callback fired with the number of transaction + /// updates applied since the last callback invocation. + /// + /// Updates are coalesced to reduce callback overhead on high-throughput + /// streams. The callback may execute on internal non-main queues. + public func setRawTransactionUpdateCountObserver(_ observer: (@Sendable (UInt64) -> Void)?) { + withCallbackStateLock { + rawTransactionUpdateCountObserver.value = observer + } + } + + private func getSavedToken() -> String? { + withStateLock { savedToken } + } + + private func performConnect(authToken: String?, isReconnect: Bool) { + withStateLock { + reconnectTask?.cancel() + reconnectTask = nil + isHandlingConnectionFailure = false + } + + stopKeepAliveLoop() + emitCounter( + "spacetimedb.connection.attempts", + tags: ["reconnect": isReconnect ? "true" : "false"] + ) + var components = URLComponents(url: serverUrl, resolvingAgainstBaseURL: false)! + components.path = "/v1/database/\(moduleName)/subscribe" + components.queryItems = [URLQueryItem(name: "compression", value: compressionMode.queryValue)] + if components.scheme == "http" { components.scheme = "ws" } + if components.scheme == "https" { components.scheme = "wss" } + + var headers: [String: String] = [:] + if let token = authToken { + headers["Authorization"] = "Bearer \(token)" + } + + let (procedureCallbacks, queryCallbacks) = withStateLock { () -> ([RequestId: (Result) -> Void], [RequestId: (Result) -> Void]) in + isTransportReady = false + sendQueue.removeAll() + pendingReducerNames.removeAll() + let procedureCallbacks = pendingProcedureCallbacks + pendingProcedureCallbacks.removeAll() + let queryCallbacks = pendingOneOffQueryCallbacks + pendingOneOffQueryCallbacks.removeAll() + pendingSubscriptionByRequestId.removeAll() + pendingUnsubscribeByRequestId.removeAll() + activeSubscriptionByQuerySetId.removeAll() + return (procedureCallbacks, queryCallbacks) + } + + withIdStateLock { + nextRequestId = RequestId(rawValue: 1) + nextQuerySetId = QuerySetId(rawValue: 1) + } + + failCallbacks( + procedureCallbacks: procedureCallbacks, + procedureError: SpacetimeClientProcedureError.disconnected, + queryCallbacks: queryCallbacks, + queryError: SpacetimeClientQueryError.disconnected + ) + + setConnectionState(isReconnect ? .reconnecting : .connecting) + + transport.connect(to: components.url!, protocols: ["v2.bsatn.spacetimedb"], headers: headers) + } + + public func disconnect() { + let (procedureCallbacks, queryCallbacks, managed) = withStateLock { + shouldStayConnected = false + reconnectTask?.cancel() + reconnectTask = nil + isTransportReady = false + sendQueue.removeAll() + pendingReducerNames.removeAll() + let procedureCallbacks = pendingProcedureCallbacks + pendingProcedureCallbacks.removeAll() + let queryCallbacks = pendingOneOffQueryCallbacks + pendingOneOffQueryCallbacks.removeAll() + pendingSubscriptionByRequestId.removeAll() + pendingUnsubscribeByRequestId.removeAll() + activeSubscriptionByQuerySetId.removeAll() + let managed = managedSubscriptions.values + managedSubscriptions.removeAll() + return (procedureCallbacks, queryCallbacks, managed) + } + + stopNetworkMonitor() + stopKeepAliveLoop() + + transport.disconnect() + + failCallbacks( + procedureCallbacks: procedureCallbacks, + procedureError: SpacetimeClientProcedureError.disconnected, + queryCallbacks: queryCallbacks, + queryError: SpacetimeClientQueryError.disconnected + ) + + for handle in managed { + handle.markEnded() + } + + setConnectionState(.disconnected) + invokeDelegateCallback(named: "delegate.on_disconnect") { $0.onDisconnect(error: nil) } + } + + private func failCallbacks( + procedureCallbacks: [RequestId: (Result) -> Void], + procedureError: Error, + queryCallbacks: [RequestId: (Result) -> Void], + queryError: Error + ) { + for callback in procedureCallbacks.values { + callback(.failure(procedureError)) + } + for callback in queryCallbacks.values { + callback(.failure(queryError)) + } + } + + // MARK: - Serialized send queue + + private func enqueue(_ data: Data) { + withStateLock { + sendQueue.append(data) + } + flushQueue() + } + + private func flushQueue() { + let toSend: [Data] = withStateLock { + guard isTransportReady else { + return [] + } + let data = sendQueue + sendQueue.removeAll() + return data + } + + for data in toSend { + emitCounter("spacetimedb.messages.out.count") + emitCounter("spacetimedb.messages.out.bytes", by: Int64(data.count)) + + transport.send(data: data) { error in + if let error = error { + Log.network.error("Send error: \(error.localizedDescription)") + } + } + } + } + + public func send(_ message: T) { + do { + let data = try encoder.encode(message) + enqueue(data) + } catch { + Log.network.error("Failed to encode message: \(error.localizedDescription)") + } + } + + public func send(_ reducerName: String, _ args: Data) { + let requestId = allocateRequestId() + withStateLock { + pendingReducerNames[requestId] = reducerName + } + let call = CallReducer(requestId: requestId, flags: 0, reducer: reducerName, args: args) + let message = ClientMessage.callReducer(call) + send(message) + } + + public func sendProcedure(_ procedureName: String, _ args: Data) { + let call = CallProcedure(requestId: allocateRequestId(), flags: 0, procedure: procedureName, args: args) + let message = ClientMessage.callProcedure(call) + send(message) + } + + public func sendProcedure( + _ procedureName: String, + _ args: Data, + completion: @escaping (Result) -> Void + ) { + sendProcedure(procedureName, args, decodeReturn: { $0 }, completion: completion) + } + + public func sendProcedure( + _ procedureName: String, + _ args: Data, + decodeReturn: @escaping (Data) throws -> R, + completion: @escaping (Result) -> Void + ) { + let requestId = allocateRequestId() + let timedCallback = makeTimedCallback(named: "procedure.completion") { (result: Result) in + switch result { + case .success(let data): + do { + completion(.success(try decodeReturn(data))) + } catch { + completion(.failure(error)) + } + case .failure(let error): + completion(.failure(error)) + } + } + + withStateLock { + pendingProcedureCallbacks[requestId] = timedCallback + } + + let call = CallProcedure(requestId: requestId, flags: 0, procedure: procedureName, args: args) + let message = ClientMessage.callProcedure(call) + send(message) + } + + public func sendProcedure( + _ procedureName: String, + _ args: Data, + responseType: R.Type, + completion: @escaping (Result) -> Void + ) { + sendProcedure( + procedureName, + args, + decodeReturn: { [decoder] data in + try decoder.decode(responseType, from: data) + }, + completion: completion + ) + } + + public func sendProcedure( + _ procedureName: String, + _ args: Data + ) async throws -> Data { + try await sendProcedure(procedureName, args, timeout: nil) + } + + public func sendProcedure( + _ procedureName: String, + _ args: Data, + timeout: Duration? + ) async throws -> Data { + try Task.checkCancellation() + let requestId = allocateRequestId() + let asyncResponse = AsyncResponseContinuation() + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + asyncResponse.install(continuation) + guard !Task.isCancelled else { + asyncResponse.resolve(.failure(CancellationError())) + return + } + + let timedCallback = makeTimedCallback(named: "procedure.completion") { result in + asyncResponse.resolve(result) + } + + withStateLock { + pendingProcedureCallbacks[requestId] = timedCallback + } + + if let timeout { + asyncResponse.setTimeoutTask( + Task { [weak self] in + try? await Task.sleep(for: timeout) + guard let self else { return } + let removed = self.withStateLock { + self.pendingProcedureCallbacks.removeValue(forKey: requestId) != nil + } + if removed { + asyncResponse.resolve(.failure(SpacetimeClientProcedureError.timeout)) + } + } + ) + } + + let call = CallProcedure(requestId: requestId, flags: 0, procedure: procedureName, args: args) + let message = ClientMessage.callProcedure(call) + send(message) + } + } onCancel: { + withStateLock { + _ = self.pendingProcedureCallbacks.removeValue(forKey: requestId) + } + asyncResponse.resolve(.failure(CancellationError())) + } + } + + public func sendProcedure( + _ procedureName: String, + _ args: Data, + responseType: R.Type + ) async throws -> R { + try await sendProcedure(procedureName, args, responseType: responseType, timeout: nil) + } + + public func sendProcedure( + _ procedureName: String, + _ args: Data, + responseType: R.Type, + timeout: Duration? + ) async throws -> R { + let raw = try await sendProcedure(procedureName, args, timeout: timeout) + return try decoder.decode(responseType, from: raw) + } + + public func oneOffQuery(_ query: String, completion: @escaping (Result) -> Void) { + let requestId = allocateRequestId() + let timedCallback = makeTimedCallback(named: "one_off_query.completion", completion) + withStateLock { + pendingOneOffQueryCallbacks[requestId] = timedCallback + } + send(ClientMessage.oneOffQuery(OneOffQuery(requestId: requestId, queryString: query))) + } + + public func oneOffQuery(_ query: String) async throws -> QueryRows { + try await oneOffQuery(query, timeout: nil) + } + + public func oneOffQuery(_ query: String, timeout: Duration?) async throws -> QueryRows { + try Task.checkCancellation() + let requestId = allocateRequestId() + let asyncResponse = AsyncResponseContinuation() + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + asyncResponse.install(continuation) + guard !Task.isCancelled else { + asyncResponse.resolve(.failure(CancellationError())) + return + } + + let timedCallback = makeTimedCallback(named: "one_off_query.completion") { result in + asyncResponse.resolve(result) + } + + withStateLock { + pendingOneOffQueryCallbacks[requestId] = timedCallback + } + + if let timeout { + asyncResponse.setTimeoutTask( + Task { [weak self] in + try? await Task.sleep(for: timeout) + guard let self else { return } + let removed = self.withStateLock { + self.pendingOneOffQueryCallbacks.removeValue(forKey: requestId) != nil + } + if removed { + asyncResponse.resolve(.failure(SpacetimeClientQueryError.timeout)) + } + } + ) + } + + send(ClientMessage.oneOffQuery(OneOffQuery(requestId: requestId, queryString: query))) + } + } onCancel: { + withStateLock { + _ = self.pendingOneOffQueryCallbacks.removeValue(forKey: requestId) + } + asyncResponse.resolve(.failure(CancellationError())) + } + } + + public func subscribe( + queries: [String], + onApplied: (() -> Void)? = nil, + onError: ((String) -> Void)? = nil + ) -> SubscriptionHandle { + let timedOnApplied = onApplied.map { callback in + makeTimedVoidCallback(named: "subscription.on_applied", callback) + } + let timedOnError = onError.map { callback in + makeTimedCallback(named: "subscription.on_error", callback) + } + let handle = SubscriptionHandle(queries: queries, client: self, onApplied: timedOnApplied, onError: timedOnError) + withStateLock { + managedSubscriptions[ObjectIdentifier(handle)] = handle + } + startSubscription(handle) + return handle + } + + public func unsubscribe(_ handle: SubscriptionHandle, sendDroppedRows: Bool = false) { + guard handle.state == .active, let querySetId = handle.querySetId else { + return + } + + let flags: UInt8 = sendDroppedRows ? 1 : 0 + let requestId = allocateRequestId() + withStateLock { + pendingUnsubscribeByRequestId[requestId] = handle + } + send(ClientMessage.unsubscribe(Unsubscribe(requestId: requestId, querySetId: querySetId, flags: flags))) + } + + public func subscribeAll(tables: [String]) { + guard !tables.isEmpty else { + return + } + let queries = tables.map { "SELECT * FROM \($0)" } + let sub = Subscribe( + queryStrings: queries, + requestId: allocateRequestId(), + querySetId: allocateQuerySetId() + ) + let message = ClientMessage.subscribe(sub) + send(message) + } + + private func startSubscription(_ handle: SubscriptionHandle) { + guard !handle.queries.isEmpty else { + handle.markError("Subscription requires at least one query.") + _ = withStateLock { + managedSubscriptions.removeValue(forKey: ObjectIdentifier(handle)) + } + return + } + + let requestId = allocateRequestId() + let querySetId = allocateQuerySetId() + handle.markPending(requestId: requestId, querySetId: querySetId) + withStateLock { + pendingSubscriptionByRequestId[requestId] = handle + } + send(ClientMessage.subscribe(Subscribe(queryStrings: handle.queries, requestId: requestId, querySetId: querySetId))) + } + + private func allocateRequestId() -> RequestId { + withIdStateLock { + let id = nextRequestId + nextRequestId = RequestId(rawValue: nextRequestId.rawValue &+ 1) + return id + } + } + + private func allocateQuerySetId() -> QuerySetId { + withIdStateLock { + let id = nextQuerySetId + nextQuerySetId = QuerySetId(rawValue: nextQuerySetId.rawValue &+ 1) + return id + } + } + + // MARK: - WebSocketTransportDelegate + + public func webSocketTransportDidConnect() { + withStateLock { + isTransportReady = true + } + flushQueue() + } + + public func webSocketTransportDidDisconnect(error: Error?) { + withStateLock { + isTransportReady = false + } + if let error = error { + handleConnectionFailure(error) + } else { + setConnectionState(.disconnected) + } + } + + public func webSocketTransportDidReceivePong() { + withStateLock { + awaitingKeepAlivePong = false + keepAliveTimeoutTask?.cancel() + keepAliveTimeoutTask = nil + } + } + + public func webSocketTransportDidReceive(data: Data) { + self.emitCounter("spacetimedb.messages.in.count") + self.emitCounter("spacetimedb.messages.in.bytes", by: Int64(data.count)) + + self.decodeQueue.async { [weak self] in + guard let self = self else { return } + let decoded = Self.decodeServerMessage(from: data) + self.handleDecodedServerMessage(decoded) + } + } + + // MARK: - Message handling + + nonisolated private static func decodeServerMessage(from data: Data) -> Result { + do { + let bsatnData = try ServerMessageFrameDecoder.decodePayload(from: data) + let decoder = BSATNDecoder() + return .success(try decoder.decode(ServerMessage.self, from: bsatnData)) + } catch { + return .failure(error) + } + } + + private func handleDecodedServerMessage(_ decoded: Result) { + switch decoded { + case .failure(let error): + Log.network.error("Failed to decode server message: \(error.localizedDescription)") + case .success(let serverMsg): + switch serverMsg { + case .initialConnection(let connection): + withStateLock { + reconnectAttempt = 0 + savedToken = connection.token + } + + setConnectionState(.connected) + startKeepAliveLoop() + invokeDelegateCallback(named: "delegate.on_identity_received") { + $0.onIdentityReceived(identity: Array(connection.identity.rawBytes), token: connection.token) + } + subscribeAll(tables: Self.clientCache.registeredTableNames) + resubscribeManagedSubscriptions() + invokeDelegateCallback(named: "delegate.on_connect") { $0.onConnect() } + case .transactionUpdate(let update): + Self.clientCache.applyTransactionUpdate(update) + notifyRawTransactionUpdateObservers() + notifyTransactionUpdate() + case .subscribeApplied(let applied): + handleSubscribeApplied(applied) + let initial = applied.asTransactionUpdate() + Self.clientCache.applyTransactionUpdate(initial) + notifyRawTransactionUpdateObservers() + notifyTransactionUpdate() + case .reducerResult(let reducerResult): + handleReducerResult(reducerResult) + case .other: + break + case .subscriptionError(let error): + Log.client.warning("Subscription error for query_set_id=\(error.querySetId): \(error.error)") + handleSubscriptionError(error) + case .procedureResult(let result): + handleProcedureResult(result) + case .unsubscribeApplied(let applied): + handleUnsubscribeApplied(applied) + case .oneOffQueryResult(let result): + handleOneOffQueryResult(result) + } + } + } + + func handleReducerResult(_ reducerResult: ReducerResult) { + let reducerName = withStateLock { + pendingReducerNames.removeValue(forKey: reducerResult.requestId) ?? "" + } + + switch reducerResult.result { + case .ok(let ok): + Self.clientCache.applyTransactionUpdate(ok.transactionUpdate) + notifyRawTransactionUpdateObservers() + notifyTransactionUpdate() + case .okEmpty: + break + case .err(let errData): + let message: String + if let decoded = try? decoder.decode(String.self, from: errData) { + message = decoded + } else if let utf8 = String(data: errData, encoding: .utf8), !utf8.isEmpty { + message = utf8 + } else { + message = "non-text payload (\(errData.count) bytes)" + } + Log.client.warning("Reducer request_id=\(reducerResult.requestId) returned error: \(message)") + invokeDelegateCallback(named: "delegate.on_reducer_error") { + $0.onReducerError(reducer: reducerName, message: message, isInternal: false) + } + case .internalError(let message): + Log.client.error("Reducer request_id=\(reducerResult.requestId) internal error: \(message)") + invokeDelegateCallback(named: "delegate.on_reducer_error") { + $0.onReducerError(reducer: reducerName, message: message, isInternal: true) + } + break + } + } + + func handleProcedureResult(_ result: ProcedureResult) { + let callback = withStateLock { + pendingProcedureCallbacks.removeValue(forKey: result.requestId) + } + + guard let callback else { + Log.client.warning("Received ProcedureResult for unknown request_id: \(result.requestId)") + return + } + + switch result.status { + case .returned(let data): + callback(.success(data)) + case .internalError(let message): + callback(.failure(SpacetimeClientProcedureError.internalError(message))) + } + } + + func handleOneOffQueryResult(_ result: OneOffQueryResult) { + let callback = withStateLock { + pendingOneOffQueryCallbacks.removeValue(forKey: result.requestId) + } + + guard let callback else { + Log.client.warning("Received OneOffQueryResult for unknown request_id: \(result.requestId)") + return + } + + switch result.result { + case .ok(let rows): + callback(.success(rows)) + case .err(let message): + callback(.failure(SpacetimeClientQueryError.serverError(message))) + } + } + + func handleSubscribeApplied(_ applied: SubscribeApplied) { + let handle = withStateLock { () -> SubscriptionHandle? in + let handle = pendingSubscriptionByRequestId.removeValue(forKey: applied.requestId) + if let handle { + activeSubscriptionByQuerySetId[applied.querySetId] = handle + } + return handle + } + + handle?.markApplied(querySetId: applied.querySetId) + } + + func handleUnsubscribeApplied(_ applied: UnsubscribeApplied) { + let handle = withStateLock { () -> SubscriptionHandle? in + let handle = pendingUnsubscribeByRequestId.removeValue(forKey: applied.requestId) + if let handle { + activeSubscriptionByQuerySetId.removeValue(forKey: applied.querySetId) + managedSubscriptions.removeValue(forKey: ObjectIdentifier(handle)) + } + return handle + } + + guard let handle else { return } + handle.markEnded() + + if let rows = applied.rows { + let update = queryRowsToTransactionUpdate(rows, querySetId: applied.querySetId, asInserts: false) + Self.clientCache.applyTransactionUpdate(update) + notifyRawTransactionUpdateObservers() + notifyTransactionUpdate() + } + } + + @inline(__always) + private func notifyRawTransactionUpdateObservers() { + let (syncObserver, shouldScheduleCountDrain) = withCallbackStateLock { () -> ((@Sendable () -> Void)?, Bool) in + pendingRawTransactionUpdateCount &+= 1 + let shouldSchedule = !rawTransactionUpdateCountDrainScheduled + if shouldSchedule { + rawTransactionUpdateCountDrainScheduled = true + } + return (rawTransactionUpdateObserver.value, shouldSchedule) + } + syncObserver?() + + guard shouldScheduleCountDrain else { return } + Task { [weak self] in + self?.drainRawTransactionUpdateCountObserver() + } + } + + private func drainRawTransactionUpdateCountObserver() { + while true { + let next = withCallbackStateLock { () -> ((@Sendable (UInt64) -> Void), UInt64)? in + guard let observer = rawTransactionUpdateCountObserver.value else { + pendingRawTransactionUpdateCount = 0 + rawTransactionUpdateCountDrainScheduled = false + return nil + } + guard pendingRawTransactionUpdateCount > 0 else { + rawTransactionUpdateCountDrainScheduled = false + return nil + } + let count = pendingRawTransactionUpdateCount + pendingRawTransactionUpdateCount = 0 + return (observer, count) + } + + guard let (observer, count) = next else { return } + observer(count) + } + } + + func handleSubscriptionError(_ error: SubscriptionError) { + let handle: SubscriptionHandle? = withStateLock { + if let requestId = error.requestId, let pending = pendingSubscriptionByRequestId.removeValue(forKey: requestId) { + managedSubscriptions.removeValue(forKey: ObjectIdentifier(pending)) + return pending + } + let active = activeSubscriptionByQuerySetId.removeValue(forKey: error.querySetId) + if let active { + managedSubscriptions.removeValue(forKey: ObjectIdentifier(active)) + } + return active + } + + handle?.markError(error.error) + } + + private func resubscribeManagedSubscriptions() { + let subsToStart = withStateLock { + activeSubscriptionByQuerySetId.removeAll() + pendingSubscriptionByRequestId.removeAll() + return managedSubscriptions.values.filter { $0.state != .ended } + } + + for handle in subsToStart { + startSubscription(handle) + } + } + + private func queryRowsToTransactionUpdate(_ rows: QueryRows, querySetId: QuerySetId, asInserts: Bool) -> TransactionUpdate { + let updates = rows.tables.map { tableRows in + let persistent = PersistentTableRows( + inserts: asInserts ? tableRows.rows : .empty, + deletes: asInserts ? .empty : tableRows.rows + ) + return TableUpdate(tableName: tableRows.table, rows: [.persistentTable(persistent)]) + } + + return TransactionUpdate(querySets: [QuerySetUpdate(querySetId: querySetId, tables: updates)]) + } + + // MARK: - Network monitoring + + private func startNetworkMonitor() { + let monitor = withStateLock { () -> NetworkMonitor? in + guard networkMonitor == nil else { + return nil + } + return NetworkMonitor() + } + guard let monitor else { return } + + monitor.onPathChange = { [weak self] isConnected in + guard let self else { return } + let shouldConnected = self.withStateLock { self.shouldStayConnected } + let state = self.withStateLock { self._connectionState } + let token = self.withStateLock { self.savedToken } + + if isConnected && shouldConnected { + Log.network.info("Network restored, attempting reconnect") + self.withStateLock { + self.reconnectAttempt = 0 + } + + guard state != .connected else { return } + self.performConnect(authToken: token, isReconnect: true) + } + } + monitor.start() + + withStateLock { + networkMonitor = monitor + } + } + + private func stopNetworkMonitor() { + let monitor = withStateLock { () -> NetworkMonitor? in + let monitor = networkMonitor + networkMonitor = nil + return monitor + } + monitor?.stop() + } + + // MARK: - Connection lifecycle + + private func setConnectionState(_ state: ConnectionState) { + let changed = withStateLock { () -> Bool in + guard _connectionState != state else { return false } + _connectionState = state + return true + } + guard changed else { return } + + emitGauge( + "spacetimedb.connection.state", + value: stateMetricValue(state), + tags: ["state": stateMetricName(state)] + ) + invokeDelegateCallback(named: "delegate.on_connection_state_change") { + $0.onConnectionStateChange(state: state) + } + } + + private func handleConnectionFailure(_ error: Error) { + let failureState = withStateLock { () -> (ConnectionState, [RequestId: (Result) -> Void], [RequestId: (Result) -> Void])? in + guard shouldStayConnected else { return nil } + guard !isHandlingConnectionFailure else { return nil } + isHandlingConnectionFailure = true + let state = _connectionState + let procCallbacks = pendingProcedureCallbacks + pendingProcedureCallbacks.removeAll() + let queryCallbacks = pendingOneOffQueryCallbacks + pendingOneOffQueryCallbacks.removeAll() + return (state, procCallbacks, queryCallbacks) + } + guard let (state, procCallbacks, queryCallbacks) = failureState else { return } + + Log.network.error("WebSocket error: \(error.localizedDescription)") + emitCounter( + "spacetimedb.connection.failures", + tags: ["state": stateMetricName(state)] + ) + stopKeepAliveLoop() + transport.disconnect() + + failCallbacks( + procedureCallbacks: procCallbacks, + procedureError: error, + queryCallbacks: queryCallbacks, + queryError: error + ) + + if state == .connecting { + invokeDelegateCallback(named: "delegate.on_connect_error") { @MainActor in $0.onConnectError(error: error) } + } + invokeDelegateCallback(named: "delegate.on_disconnect") { @MainActor in $0.onDisconnect(error: error) } + + guard let reconnectDelay = nextReconnectDelay() else { + withStateLock { + shouldStayConnected = false + } + setConnectionState(.disconnected) + withStateLock { + isHandlingConnectionFailure = false + } + return + } + + setConnectionState(.reconnecting) + + let connected = withStateLock { networkMonitor?.isConnected ?? true } + + // If network is unavailable, defer reconnection until path restores. + guard connected else { + Log.network.info("Network unavailable, deferring reconnect until path restores") + withStateLock { + isHandlingConnectionFailure = false + } + return + } + + withStateLock { + reconnectTask?.cancel() + reconnectTask = Task { [weak self] in + try? await Task.sleep(for: reconnectDelay) + guard let self else { return } + let (shouldStay, token) = self.withStateLock { + (self.shouldStayConnected, self.savedToken) + } + guard shouldStay else { + self.withStateLock { self.isHandlingConnectionFailure = false } + return + } + self.withStateLock { self.isHandlingConnectionFailure = false } + self.performConnect(authToken: token, isReconnect: true) + } + } + } + + // MARK: - Keepalive + + private func startKeepAliveLoop() { + stopKeepAliveLoop() + withStateLock { + keepAliveTask = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + let interval = self.withStateLock { self.keepAlivePingInterval } + + try? await Task.sleep(for: interval) + guard !Task.isCancelled else { return } + self.sendKeepAlivePing() + } + } + } + } + + private func stopKeepAliveLoop() { + withStateLock { + keepAliveTask?.cancel() + keepAliveTask = nil + keepAliveTimeoutTask?.cancel() + keepAliveTimeoutTask = nil + awaitingKeepAlivePong = false + } + } + + private func sendKeepAlivePing() { + let (shouldPing, isTimeout) = withStateLock { () -> (Bool, Bool) in + guard shouldStayConnected, _connectionState == .connected else { + return (false, false) + } + guard !awaitingKeepAlivePong else { + return (false, true) + } + return (true, false) + } + + if isTimeout { + handleConnectionFailure(SpacetimeClientConnectionError.keepAliveTimeout) + return + } + guard shouldPing else { return } + + withStateLock { + awaitingKeepAlivePong = true + keepAliveTimeoutTask?.cancel() + let timeout = keepAlivePongTimeout + keepAliveTimeoutTask = Task { [weak self] in + try? await Task.sleep(for: timeout) + guard let self else { return } + let awaiting = self.withStateLock { + let val = self.awaitingKeepAlivePong + self.awaitingKeepAlivePong = false + return val + } + if awaiting { + self.handleConnectionFailure(SpacetimeClientConnectionError.keepAliveTimeout) + } + } + } + transport.sendPing { [weak self] error in + guard let self else { return } + if let error { + self.withStateLock { + self.awaitingKeepAlivePong = false + self.keepAliveTimeoutTask?.cancel() + self.keepAliveTimeoutTask = nil + } + self.handleConnectionFailure(error) + } + } + } + + private func nextReconnectDelay() -> Duration? { + withStateLock { + guard let reconnectPolicy else { return nil } + reconnectAttempt += 1 + return reconnectPolicy.delay(forAttempt: reconnectAttempt) + } + } + + private func emitCounter(_ name: String, by value: Int64 = 1, tags: [String: String] = [:]) { + let metrics = SpacetimeObservability.metrics + guard !(metrics is NoopSpacetimeMetrics) else { return } + metrics.incrementCounter(name, by: value, tags: tags) + } + + private func emitGauge(_ name: String, value: Double, tags: [String: String] = [:]) { + let metrics = SpacetimeObservability.metrics + guard !(metrics is NoopSpacetimeMetrics) else { return } + metrics.recordGauge(name, value: value, tags: tags) + } + + private func emitTiming(_ name: String, milliseconds: Double, tags: [String: String] = [:]) { + let metrics = SpacetimeObservability.metrics + guard !(metrics is NoopSpacetimeMetrics) else { return } + metrics.recordTiming(name, milliseconds: milliseconds, tags: tags) + } + + private func invokeDelegateCallback( + named callbackName: String, + _ callback: @escaping @MainActor (SpacetimeClientDelegate) -> Void + ) { + let delegate = withCallbackStateLock { _delegate } + + guard let delegate else { return } + if Thread.isMainThread { + MainActor.assumeIsolated { + self.emitTimedCallbackMetric(named: callbackName) { + callback(delegate) + } + } + return + } + + Task { @MainActor [weak self] in + guard let self else { return } + self.emitTimedCallbackMetric(named: callbackName) { + callback(delegate) + } + } + } + + private func notifyTransactionUpdate() { + guard coalesceTransactionUpdateCallbacks else { + invokeDelegateCallback(named: "delegate.on_transaction_update") { $0.onTransactionUpdate(message: nil) } + return + } + + let shouldSchedule = withCallbackStateLock { () -> Bool in + if transactionUpdateCallbackScheduled { + transactionUpdateCallbackDirty = true + return false + } + transactionUpdateCallbackScheduled = true + transactionUpdateCallbackDirty = false + return true + } + guard shouldSchedule else { return } + + if Thread.isMainThread { + MainActor.assumeIsolated { + drainTransactionUpdateCallbacksOnMainActor() + } + return + } + + Task { @MainActor [weak self] in + self?.drainTransactionUpdateCallbacksOnMainActor() + } + } + + @MainActor + private func drainTransactionUpdateCallbacksOnMainActor() { + while true { + let delegate = withCallbackStateLock { _delegate } + if let delegate { + emitTimedCallbackMetric(named: "delegate.on_transaction_update") { + delegate.onTransactionUpdate(message: nil) + } + } + + let shouldContinue = withCallbackStateLock { () -> Bool in + if transactionUpdateCallbackDirty { + transactionUpdateCallbackDirty = false + return true + } + transactionUpdateCallbackScheduled = false + return false + } + if !shouldContinue { + break + } + } + } + + private func makeTimedVoidCallback( + named callbackName: String, + _ callback: @escaping () -> Void + ) -> (() -> Void) { + { [weak self] in + guard let self else { + callback() + return + } + self.emitTimedCallbackMetric(named: callbackName, callback) + } + } + + private func makeTimedCallback( + named callbackName: String, + _ callback: @escaping (T) -> Void + ) -> ((T) -> Void) { + { [weak self] value in + guard let self else { + callback(value) + return + } + self.emitTimedCallbackMetric(named: callbackName) { + callback(value) + } + } + } + + private func emitTimedCallbackMetric(named callbackName: String, _ callback: () -> Void) { + let metrics = SpacetimeObservability.metrics + guard !(metrics is NoopSpacetimeMetrics) else { + callback() + return + } + + let start = ContinuousClock.now + callback() + let elapsed = start.duration(to: ContinuousClock.now) + let components = elapsed.components + let milliseconds = + (Double(components.seconds) * 1000) + + (Double(components.attoseconds) / 1_000_000_000_000_000) + metrics.recordTiming( + "spacetimedb.callback.latency", + milliseconds: milliseconds, + tags: ["callback": callbackName] + ) + } + + private func stateMetricName(_ state: ConnectionState) -> String { + switch state { + case .disconnected: + return "disconnected" + case .connecting: + return "connecting" + case .connected: + return "connected" + case .reconnecting: + return "reconnecting" + } + } + + private func stateMetricValue(_ state: ConnectionState) -> Double { + switch state { + case .disconnected: + return 0 + case .connecting: + return 1 + case .connected: + return 2 + case .reconnecting: + return 3 + } + } +} + +#if DEBUG +extension SpacetimeClient { + func _test_simulateConnectionFailure(_ error: Error, shouldStayConnected: Bool = true) { + withStateLock { + self.shouldStayConnected = shouldStayConnected + } + handleConnectionFailure(error) + } + + func _test_deliverServerMessage(_ message: ServerMessage) { + handleDecodedServerMessage(.success(message)) + } + + func _test_setConnectionState(_ state: ConnectionState) { + setConnectionState(state) + } + + func _test_pendingProcedureCallbackCount() -> Int { + withStateLock { pendingProcedureCallbacks.count } + } + + func _test_pendingOneOffQueryCallbackCount() -> Int { + withStateLock { pendingOneOffQueryCallbacks.count } + } +} +#endif diff --git a/sdks/swift/Sources/SpacetimeDB/RuntimeTypes.swift b/sdks/swift/Sources/SpacetimeDB/RuntimeTypes.swift new file mode 100644 index 00000000000..177c866c91b --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/RuntimeTypes.swift @@ -0,0 +1,195 @@ +import Foundation + +public struct Identity: Codable, Sendable, Hashable { + public static let byteCount = 32 + public var rawBytes: Data + + public init(rawBytes: Data) { + self.rawBytes = rawBytes + } +} + +public struct ClientConnectionId: Codable, Sendable, Hashable { + public static let byteCount = 16 + public var rawBytes: Data + + public init(rawBytes: Data) { + self.rawBytes = rawBytes + } +} + +public struct QuerySetId: Codable, Sendable, Hashable, RawRepresentable { + public var rawValue: UInt32 + public init(rawValue: UInt32) { self.rawValue = rawValue } +} + +public struct RequestId: Codable, Sendable, Hashable, RawRepresentable { + public var rawValue: UInt32 + public init(rawValue: UInt32) { self.rawValue = rawValue } +} + +public struct RawIdentifier: Codable, Sendable, Hashable, RawRepresentable { + public var rawValue: String + public init(rawValue: String) { self.rawValue = rawValue } +} + +public struct TimeDurationMicros: Codable, Sendable, Hashable, RawRepresentable { + public var rawValue: UInt64 + public init(rawValue: UInt64) { self.rawValue = rawValue } +} + +public enum ScheduleAt: Codable, Sendable { + case interval(UInt64) + case time(UInt64) +} + +public enum SpacetimeResult: Codable, Sendable { + case ok(Ok) + case err(Err) +} + +extension Identity: BSATNSpecialDecodable { + public static func decodeBSATN(from reader: inout BSATNReader) throws -> Identity { + return Identity(rawBytes: try reader.readBytes(count: Self.byteCount)) + } +} + +extension Identity: BSATNSpecialEncodable { + public func encodeBSATN(to storage: inout BSATNStorage) throws { + guard rawBytes.count == Self.byteCount else { + throw BSATNEncodingError.lengthOutOfRange + } + storage.append(rawBytes) + } +} + +extension ClientConnectionId: BSATNSpecialDecodable { + public static func decodeBSATN(from reader: inout BSATNReader) throws -> ClientConnectionId { + return ClientConnectionId(rawBytes: try reader.readBytes(count: Self.byteCount)) + } +} + +extension ClientConnectionId: BSATNSpecialEncodable { + public func encodeBSATN(to storage: inout BSATNStorage) throws { + guard rawBytes.count == Self.byteCount else { + throw BSATNEncodingError.lengthOutOfRange + } + storage.append(rawBytes) + } +} + +extension QuerySetId: BSATNSpecialDecodable { + public static func decodeBSATN(from reader: inout BSATNReader) throws -> QuerySetId { + return QuerySetId(rawValue: try reader.read(UInt32.self)) + } +} + +extension QuerySetId: BSATNSpecialEncodable { + public func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.append(rawValue) + } +} + +extension RequestId: BSATNSpecialDecodable { + public static func decodeBSATN(from reader: inout BSATNReader) throws -> RequestId { + return RequestId(rawValue: try reader.read(UInt32.self)) + } +} + +extension RequestId: BSATNSpecialEncodable { + public func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.append(rawValue) + } +} + +extension RawIdentifier: BSATNSpecialDecodable { + public static func decodeBSATN(from reader: inout BSATNReader) throws -> RawIdentifier { + return RawIdentifier(rawValue: try reader.readString()) + } +} + +extension RawIdentifier: BSATNSpecialEncodable { + public func encodeBSATN(to storage: inout BSATNStorage) throws { + try storage.appendString(rawValue) + } +} + +extension TimeDurationMicros: BSATNSpecialDecodable { + public static func decodeBSATN(from reader: inout BSATNReader) throws -> TimeDurationMicros { + return TimeDurationMicros(rawValue: try reader.read(UInt64.self)) + } +} + +extension TimeDurationMicros: BSATNSpecialEncodable { + public func encodeBSATN(to storage: inout BSATNStorage) throws { + storage.append(rawValue) + } +} + +extension ScheduleAt: BSATNSpecialDecodable { + public static func decodeBSATN(from reader: inout BSATNReader) throws -> ScheduleAt { + let tag = try reader.read(UInt8.self) + switch tag { + case 0: + return .interval(try reader.read(UInt64.self)) + case 1: + return .time(try reader.read(UInt64.self)) + default: + throw BSATNDecodingError.invalidType + } + } +} + +extension ScheduleAt: BSATNSpecialEncodable { + public func encodeBSATN(to storage: inout BSATNStorage) throws { + switch self { + case .interval(let value): + storage.append(0 as UInt8) + storage.append(value) + case .time(let value): + storage.append(1 as UInt8) + storage.append(value) + } + } +} + +extension SpacetimeResult: BSATNSpecialDecodable { + public static func decodeBSATN(from reader: inout BSATNReader) throws -> SpacetimeResult { + let tag = try reader.read(UInt8.self) + switch tag { + case 0: + if let specialType = Ok.self as? BSATNSpecialDecodable.Type { + return .ok(try specialType.decodeBSATN(from: &reader) as! Ok) + } + return .ok(try reader.fallbackDecode(Ok.self)) + case 1: + if let specialType = Err.self as? BSATNSpecialDecodable.Type { + return .err(try specialType.decodeBSATN(from: &reader) as! Err) + } + return .err(try reader.fallbackDecode(Err.self)) + default: + throw BSATNDecodingError.invalidType + } + } +} + +extension SpacetimeResult: BSATNSpecialEncodable { + public func encodeBSATN(to storage: inout BSATNStorage) throws { + switch self { + case .ok(let value): + storage.append(0 as UInt8) + if let bsatnSpecial = value as? BSATNSpecialEncodable { + try bsatnSpecial.encodeBSATN(to: &storage) + } else { + try storage.fallbackEncode(value) + } + case .err(let value): + storage.append(1 as UInt8) + if let bsatnSpecial = value as? BSATNSpecialEncodable { + try bsatnSpecial.encodeBSATN(to: &storage) + } else { + try storage.fallbackEncode(value) + } + } + } +} diff --git a/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/Apple-CI-Matrix.md b/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/Apple-CI-Matrix.md new file mode 100644 index 00000000000..06c9d919ef8 --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/Apple-CI-Matrix.md @@ -0,0 +1,47 @@ +# Apple CI Matrix (macOS, iOS Simulator) + +## Goals + +- Keep package quality visible to Apple app teams. +- Verify host and simulator compatibility in every PR. +- Keep platform posture explicit: visionOS is not targeted yet. + +## Recommended matrix + +- `macOS`: + - `swift test --package-path sdks/swift` + - lockfile validation + - demo builds + - benchmark smoke + - DocC build smoke +- `iOS Simulator`: + - cross-compile SDK target using iOS simulator SDK +- `visionOS`: + - intentionally unsupported in this package right now + - CI fails if `.visionOS(...)` is added without a coordinated posture update + +## iOS simulator build command + +```bash +IOS_SDK_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)" +swift build \ + --package-path sdks/swift \ + --target SpacetimeDB \ + --triple arm64-apple-ios17.0-simulator \ + --sdk "$IOS_SDK_PATH" +``` + +## visionOS posture guard + +```bash +if rg -q '\.visionOS\(' sdks/swift/Package.swift; then + echo "visionOS currently unsupported; update CI/docs posture before enabling." + exit 1 +fi +``` + +## DocC smoke command + +```bash +tools/swift-docc-smoke.sh +``` diff --git a/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/Publishing-and-Swift-Package-Index.md b/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/Publishing-and-Swift-Package-Index.md new file mode 100644 index 00000000000..f158514a27c --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/Publishing-and-Swift-Package-Index.md @@ -0,0 +1,47 @@ +# Publishing DocC and Submitting to Swift Package Index + +## Repository shape requirement + +Swift Package Manager dependencies and Swift Package Index expect a package at repository root. + +This monorepo places the Swift package under `sdks/swift`, so public distribution should use a dedicated Swift package repository (for example `spacetimedb-swift`) that mirrors this directory at root. + +## 1. Prepare the package repo + +- Mirror `sdks/swift` to a standalone repository root. +- Keep `Package.swift`, `Package.resolved`, `Sources`, and `Tests` at that root. +- Keep `.spi.yml` at that root (see file in this SDK directory). + +## 2. Tag releases + +- Use semantic versioning tags (`v0.1.0`, `v0.2.0`, ...). +- Ensure the tag includes updated DocC content and changelog notes. + +## 3. Build DocC locally + +From package root: + +```bash +tools/swift-docc-smoke.sh +``` + +## 4. Validate package and docs + +```bash +swift test +swift package resolve --force-resolved-versions +``` + +## 5. Submit to Swift Package Index + +1. Open [https://swiftpackageindex.com/add-a-package](https://swiftpackageindex.com/add-a-package). +2. Submit the public package repository URL. +3. Verify docs are generated for target `SpacetimeDB`. +4. Add package keywords, README metadata, and compatibility notes. + +## 6. Release checklist + +- CI green across macOS + iOS simulator builds +- Benchmark smoke pass +- DocC builds without errors +- Tag pushed and visible on SPI diff --git a/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/SpacetimeDB.md b/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/SpacetimeDB.md new file mode 100644 index 00000000000..3110b9f2fcd --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/SpacetimeDB.md @@ -0,0 +1,34 @@ +# ``SpacetimeDB`` + +Native Swift SDK for SpacetimeDB realtime clients on Apple platforms. + +## Overview + +`SpacetimeDB` provides: + +- BSATN protocol encoding/decoding +- WebSocket transport and reconnect support +- Typed table cache integration +- Reducer/procedure/query client APIs +- Apple platform support for macOS and iOS + +## Key Types + +- ``SpacetimeClient`` +- ``SpacetimeClientDelegate`` +- ``ReconnectPolicy`` +- ``CompressionMode`` +- ``SubscriptionHandle`` +- ``ClientCache`` +- ``TableCache`` +- ``KeychainTokenStore`` + +## Tutorials + +- [Tutorial: Build Your First SpacetimeDB Swift Client](doc:Tutorial-Build-First-Client) +- [Tutorial: Auth Tokens, Keychain, and Reconnect](doc:Tutorial-Auth-Reconnect) + +## Publishing + +- [Publishing DocC and Submitting to Swift Package Index](doc:Publishing-and-Swift-Package-Index) +- [Apple CI Matrix (macOS, iOS Simulator)](doc:Apple-CI-Matrix) diff --git a/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/Tutorial-Auth-Reconnect.md b/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/Tutorial-Auth-Reconnect.md new file mode 100644 index 00000000000..e81ebaecdda --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/Tutorial-Auth-Reconnect.md @@ -0,0 +1,55 @@ +# Tutorial: Auth Tokens, Keychain, and Reconnect + +## Goal + +Persist auth tokens across launches and configure resilient reconnect behavior. + +## 1. Configure a token store + +```swift +let tokenStore = KeychainTokenStore(service: "com.example.myapp.spacetimedb") +``` + +## 2. Reuse stored token on connect + +```swift +let savedToken = tokenStore.load(forModule: "my-module") +client.connect(token: savedToken) +``` + +## 3. Save token when identity is received + +```swift +func onIdentityReceived(identity: [UInt8], token: String) { + tokenStore.save(token: token, forModule: "my-module") +} +``` + +## 4. Tune reconnect policy + +```swift +let policy = ReconnectPolicy( + maxRetries: nil, + initialDelaySeconds: 1.0, + maxDelaySeconds: 30.0, + multiplier: 2.0, + jitterRatio: 0.2 +) +``` + +## 5. Create client with reconnect and compression config + +```swift +let client = SpacetimeClient( + serverUrl: URL(string: "http://127.0.0.1:3000")!, + moduleName: "my-module", + reconnectPolicy: policy, + compressionMode: .gzip +) +``` + +`SpacetimeClient` includes connectivity monitoring and defers reconnect attempts while offline. + +## Next + +- [Publishing DocC and Submitting to Swift Package Index](doc:Publishing-and-Swift-Package-Index) diff --git a/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/Tutorial-Build-First-Client.md b/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/Tutorial-Build-First-Client.md new file mode 100644 index 00000000000..0910c82ec41 --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.docc/Tutorial-Build-First-Client.md @@ -0,0 +1,76 @@ +# Tutorial: Build Your First SpacetimeDB Swift Client + +## Goal + +Create a minimal client that connects, subscribes, and reacts to updates. + +## 1. Add the package + +```swift +dependencies: [ + .package(url: "https://github.com//spacetimedb-swift.git", from: "0.1.0"), +] +``` + +Add target dependency: + +```swift +.product(name: "SpacetimeDB", package: "spacetimedb-swift") +``` + +## 2. Register generated tables + +```swift +SpacetimeModule.registerTables() +``` + +## 3. Create and connect a client + +```swift +let client = SpacetimeClient( + serverUrl: URL(string: "http://127.0.0.1:3000")!, + moduleName: "my-module" +) +client.delegate = self +client.connect() +``` + +## 4. Subscribe to data + +Assuming `import os` is present in the file: + +```swift +let logger = Logger(subsystem: "com.example.myapp", category: "SpacetimeClient") + +let handle = client.subscribe( + queries: ["SELECT * FROM person"], + onApplied: { logger.info("Initial snapshot applied") }, + onError: { message in + logger.error("Subscription failed: \(message, privacy: .public)") + } +) +``` + +## 5. Read replicated rows + +```swift +let people = PersonTable.cache.rows +``` + +## 6. Send reducers/procedures + +```swift +Add.invoke(name: "Avi") +let result: String = try await client.sendProcedure("say_hello", Data(), responseType: String.self) +``` + +## 7. Disconnect cleanly + +```swift +handle.unsubscribe() +client.disconnect() +``` + +## Next + +- [Tutorial: Auth Tokens, Keychain, and Reconnect](doc:Tutorial-Auth-Reconnect) diff --git a/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.swift b/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.swift new file mode 100644 index 00000000000..ce7d5538f8f --- /dev/null +++ b/sdks/swift/Sources/SpacetimeDB/SpacetimeDB.swift @@ -0,0 +1,193 @@ +import Foundation +import Synchronization + +public enum CompressionMode: String, Sendable { + case none = "None" + case gzip = "Gzip" + case brotli = "Brotli" + + var queryValue: String { + rawValue + } +} + +public struct ReconnectPolicy: Sendable, Equatable { + public var maxRetries: Int? + public var initialDelaySeconds: TimeInterval + public var maxDelaySeconds: TimeInterval + public var multiplier: Double + public var jitterRatio: Double + + public init( + maxRetries: Int? = nil, + initialDelaySeconds: TimeInterval = 1.0, + maxDelaySeconds: TimeInterval = 30.0, + multiplier: Double = 2.0, + jitterRatio: Double = 0.2 + ) { + self.maxRetries = maxRetries + self.initialDelaySeconds = initialDelaySeconds + self.maxDelaySeconds = maxDelaySeconds + self.multiplier = multiplier + self.jitterRatio = jitterRatio + } + + func delaySeconds(forAttempt attempt: Int, randomUnit: Double = Double.random(in: 0...1)) -> TimeInterval? { + guard attempt > 0 else { return nil } + if let maxRetries, attempt > maxRetries { + return nil + } + + let boundedInitial = max(0, initialDelaySeconds) + let boundedMax = max(boundedInitial, maxDelaySeconds) + let boundedMultiplier = max(1.0, multiplier) + let exponential = boundedInitial * pow(boundedMultiplier, Double(attempt - 1)) + let baseDelay = min(boundedMax, exponential) + + let boundedJitter = min(max(jitterRatio, 0), 1) + guard boundedJitter > 0 else { + return baseDelay + } + + let boundedRandom = min(max(randomUnit, 0), 1) + let jitterRange = baseDelay * boundedJitter + let jitterOffset = ((boundedRandom * 2) - 1) * jitterRange + return max(0, baseDelay + jitterOffset) + } + + func delay(forAttempt attempt: Int) -> Duration? { + guard let seconds = delaySeconds(forAttempt: attempt) else { return nil } + return .milliseconds(Int64((seconds * 1000).rounded())) + } +} + +public enum SubscriptionState: Sendable { + case pending + case active + case ended +} + +public enum ConnectionState: Sendable, Equatable { + case disconnected + case connecting + case connected + case reconnecting +} + +public final class SubscriptionHandle: @unchecked Sendable { + private struct UnsafeSendable: @unchecked Sendable { + var value: Value + } + + private struct State { + var state: SubscriptionState = .pending + var querySetId: QuerySetId? + var requestId: RequestId? + var onApplied: UnsafeSendable<(() -> Void)?> = UnsafeSendable(value: nil) + var onError: UnsafeSendable<((String) -> Void)?> = UnsafeSendable(value: nil) + } + + public let queries: [String] + private let stateLock: Mutex = Mutex(State()) + + public var state: SubscriptionState { + stateLock.withLock { state in + state.state + } + } + + var querySetId: QuerySetId? { + stateLock.withLock { state in + state.querySetId + } + } + + var requestId: RequestId? { + stateLock.withLock { state in + state.requestId + } + } + + weak var client: SpacetimeClient? + + init( + queries: [String], + client: SpacetimeClient, + onApplied: (() -> Void)?, + onError: ((String) -> Void)? + ) { + self.queries = queries + self.client = client + stateLock.withLock { state in + state.onApplied.value = onApplied + state.onError.value = onError + } + } + + public func unsubscribe(sendDroppedRows: Bool = false) { + client?.unsubscribe(self, sendDroppedRows: sendDroppedRows) + } + + func markPending(requestId: RequestId, querySetId: QuerySetId) { + stateLock.withLock { state in + state.requestId = requestId + state.querySetId = querySetId + state.state = .pending + } + } + + func markApplied(querySetId: QuerySetId) { + let applied = stateLock.withLock { state in + state.requestId = nil + state.querySetId = querySetId + state.state = .active + return state.onApplied.value + } + + if let applied { + if Thread.isMainThread { + applied() + } else { + Task { @MainActor in + applied() + } + } + } + } + + func markError(_ message: String) { + let error = stateLock.withLock { state in + state.state = .ended + let callback = state.onError.value + state.onApplied.value = nil + state.onError.value = nil + return callback + } + + if let error { + if Thread.isMainThread { + error(message) + } else { + Task { @MainActor in + error(message) + } + } + } + } + + func markEnded() { + stateLock.withLock { state in + state.state = .ended + state.requestId = nil + state.querySetId = nil + state.onApplied.value = nil + state.onError.value = nil + } + } +} + +public enum SpacetimeClientQueryError: Error, Equatable { + case serverError(String) + case disconnected + case timeout +} diff --git a/sdks/swift/Tests/SpacetimeDBTests/BSATNTests.swift b/sdks/swift/Tests/SpacetimeDBTests/BSATNTests.swift new file mode 100644 index 00000000000..49e4fdd6cc4 --- /dev/null +++ b/sdks/swift/Tests/SpacetimeDBTests/BSATNTests.swift @@ -0,0 +1,269 @@ +import XCTest +@testable import SpacetimeDB + +final class BSATNTests: XCTestCase { + + struct Person: Codable, Equatable { + var id: Int32 + var name: String + var isActive: Bool + var score: Double + } + + struct Team: Codable, Equatable { + var name: String + var members: [Person] + var maybeScore: Double? + } + + enum WeaponKind: UInt8, Codable, Equatable { + case sword = 0 + case shuriken = 1 + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let tag = try container.decode(UInt8.self) + guard let value = Self(rawValue: tag) else { + throw BSATNDecodingError.invalidType + } + self = value + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.rawValue) + } + } + + enum CombatEvent: Codable, Equatable { + case joined(Person) + case attacked(targetId: UInt32) + case respawned + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let tag = try container.decode(UInt8.self) + switch tag { + case 0: + self = .joined(try container.decode(Person.self)) + case 1: + self = .attacked(targetId: try container.decode(UInt32.self)) + case 2: + self = .respawned + default: + throw BSATNDecodingError.invalidType + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .joined(let person): + try container.encode(UInt8(0)) + try container.encode(person) + case .attacked(let targetId): + try container.encode(UInt8(1)) + try container.encode(targetId) + case .respawned: + try container.encode(UInt8(2)) + } + } + } + + func testEncodeDecodePrimitiveString() throws { + let string = "Hello SpacetimeDB" + let encoder = BSATNEncoder() + let data = try encoder.encode(string) + + let decoder = BSATNDecoder() + let decoded = try decoder.decode(String.self, from: data) + XCTAssertEqual(string, decoded) + + // Let's verify exact bytes for string manually just in case + // Length of string is 17 = 0x11 + // UInt32 length prefix: [0x11, 0x00, 0x00, 0x00] + let lengthBytes = data.prefix(4) + XCTAssertEqual(lengthBytes, Data([0x11, 0x00, 0x00, 0x00])) + } + + func testEncodeDecodeStruct() throws { + let person = Person(id: 42, name: "Alice", isActive: true, score: 3.14) + + let data = try BSATNEncoder().encode(person) + let decoded = try BSATNDecoder().decode(Person.self, from: data) + + XCTAssertEqual(person, decoded) + } + + func testEncodeDecodeArrayAndOptional() throws { + let p1 = Person(id: 1, name: "Bob", isActive: false, score: 0.0) + let p2 = Person(id: 2, name: "Charlie", isActive: true, score: 100.5) + + let team = Team(name: "Winners", members: [p1, p2], maybeScore: 50.0) + let teamData = try BSATNEncoder().encode(team) + let decodedTeam = try BSATNDecoder().decode(Team.self, from: teamData) + XCTAssertEqual(team, decodedTeam) + + let teamNoScore = Team(name: "Losers", members: [], maybeScore: nil) + let dataNoScore = try BSATNEncoder().encode(teamNoScore) + let decodedNoScore = try BSATNDecoder().decode(Team.self, from: dataNoScore) + XCTAssertEqual(teamNoScore, decodedNoScore) + + XCTAssertEqual(dataNoScore.count, 15) + XCTAssertEqual(dataNoScore.last, 0x01) // maybeScore nil = 1 (SpacetimeDB Option::none) + } + + func testEncodeDecodeOptionalNestedSpecialTypes() throws { + struct Payload: Codable, Equatable { + var maybeInts: [Int32]? + } + + let some = Payload(maybeInts: [10, 20, 30]) + let encodedSome = try BSATNEncoder().encode(some) + let decodedSome = try BSATNDecoder().decode(Payload.self, from: encodedSome) + XCTAssertEqual(decodedSome, some) + + let none = Payload(maybeInts: nil) + let encodedNone = try BSATNEncoder().encode(none) + let decodedNone = try BSATNDecoder().decode(Payload.self, from: encodedNone) + XCTAssertEqual(decodedNone, none) + } + + func testEncodeDecodeSumAndPlainEnums() throws { + struct Envelope: Codable, Equatable { + var event: CombatEvent + var weapon: WeaponKind + } + + let joined = Envelope( + event: .joined(Person(id: 7, name: "Ninja", isActive: true, score: 9.5)), + weapon: .sword + ) + let attacked = Envelope(event: .attacked(targetId: 99), weapon: .shuriken) + let respawned = Envelope(event: .respawned, weapon: .sword) + + for value in [joined, attacked, respawned] { + let encoded = try BSATNEncoder().encode(value) + let decoded = try BSATNDecoder().decode(Envelope.self, from: encoded) + XCTAssertEqual(decoded, value) + } + } + + func testDecodePlayerRowFromWireSample() throws { + struct PlayerWireVCurrent: Codable, Equatable { + var id: UInt64 + var name: String + var x: Float + var y: Float + var health: UInt32 + var weaponCount: UInt32 + var kills: UInt32 + var respawnAtMicros: Int64 + var isReady: Bool + var lobbyId: UInt64? + } + + // Captured from runtime failure log in NinjaGame. + let rowHex = "d934dcd1373395b609000000506c617965722034330000fa430000fa4364000000000000000000000000000000000000000001" + let rowData = try XCTUnwrap(Data(hex: rowHex)) + + let decoded = try BSATNDecoder().decode(PlayerWireVCurrent.self, from: rowData) + XCTAssertEqual(decoded.name, "Player 43") + XCTAssertFalse(decoded.isReady) + XCTAssertNil(decoded.lobbyId) + } + + func testDecodeRejectsInvalidBoolByte() { + let invalidBool = Data([0x02]) + XCTAssertThrowsError(try BSATNDecoder().decode(Bool.self, from: invalidBool)) { error in + XCTAssertEqual(error as? BSATNDecodingError, .invalidType) + } + } + + func testDecodeRejectsInvalidOptionalTag() { + struct Payload: Codable, Equatable { + var maybeValue: UInt32? + } + + // Option tag must be 0 (Some) or 1 (None). + let invalidTag = Data([0x02]) + XCTAssertThrowsError(try BSATNDecoder().decode(Payload.self, from: invalidTag)) { error in + XCTAssertEqual(error as? BSATNDecodingError, .invalidType) + } + } + + func testEncodeDecodeIdentityAndConnectionId() throws { + let identityBytes = Data((0.. = .ok(7) + let encodedOk = try BSATNEncoder().encode(ok) + let decodedOk = try BSATNDecoder().decode(SpacetimeResult.self, from: encodedOk) + guard case .ok(let okValue) = decodedOk else { + return XCTFail("Expected .ok") + } + XCTAssertEqual(okValue, 7) + + let err: SpacetimeResult = .err("nope") + let encodedErr = try BSATNEncoder().encode(err) + let decodedErr = try BSATNDecoder().decode(SpacetimeResult.self, from: encodedErr) + guard case .err(let errValue) = decodedErr else { + return XCTFail("Expected .err") + } + XCTAssertEqual(errValue, "nope") + } +} + +private extension Data { + init?(hex: String) { + let chars = Array(hex) + guard chars.count % 2 == 0 else { return nil } + var out = Data(capacity: chars.count / 2) + var i = 0 + while i < chars.count { + guard + let hi = chars[i].hexDigitValue, + let lo = chars[i + 1].hexDigitValue + else { return nil } + out.append(UInt8(hi * 16 + lo)) + i += 2 + } + self = out + } +} diff --git a/sdks/swift/Tests/SpacetimeDBTests/CacheTests.swift b/sdks/swift/Tests/SpacetimeDBTests/CacheTests.swift new file mode 100644 index 00000000000..4dfe8149463 --- /dev/null +++ b/sdks/swift/Tests/SpacetimeDBTests/CacheTests.swift @@ -0,0 +1,176 @@ +import XCTest +@testable import SpacetimeDB + +final class CacheTests: XCTestCase { + + struct Person: Codable, Identifiable, Equatable, Sendable { + var id: UInt32 + var name: String + } + + @MainActor + func testClientCacheRouting() throws { + let clientCache = ClientCache() + let personCache = TableCache(tableName: "Person") + clientCache.registerTable(name: "Person", cache: personCache) + + let encoder = BSATNEncoder() + let personBytes = try encoder.encode(Person(id: 42, name: "Alice")) + + // Build a v2 TransactionUpdate payload with one QuerySetUpdate / table row insert. + var rawPayload = Data() + rawPayload.append(1 as UInt32) // query_sets count + rawPayload.append(1 as UInt32) // query_set_id + rawPayload.append(1 as UInt32) // tables count + + rawPayload.append(try encoder.encode("Person")) // table_name + rawPayload.append(1 as UInt32) // rows count + rawPayload.append(0 as UInt8) // TableUpdateRows::PersistentTable + + rawPayload.append(0 as UInt8) // inserts.size_hint FixedSize + rawPayload.append(UInt16(personBytes.count)) // insert row size + rawPayload.append(UInt32(personBytes.count)) // inserts rowsData length + rawPayload.append(personBytes) + + rawPayload.append(0 as UInt8) // deletes.size_hint FixedSize + rawPayload.append(0 as UInt16) // delete row size + rawPayload.append(0 as UInt32) // deletes rowsData length + + let decoder = BSATNDecoder() + let transactionUpdate = try decoder.decode(TransactionUpdate.self, from: rawPayload) + + clientCache.applyTransactionUpdate(transactionUpdate) + personCache.sync() + + XCTAssertEqual(personCache.rows.count, 1) + XCTAssertEqual(personCache.rows[0].id, 42) + XCTAssertEqual(personCache.rows[0].name, "Alice") + } + + @MainActor + func testRegisterTableIsIdempotentForSameType() throws { + let clientCache = ClientCache() + clientCache.registerTable(tableName: "Person", rowType: Person.self) + + let firstCache: TableCache = clientCache.getTableCache(tableName: "Person") + let rowBytes = try BSATNEncoder().encode(Person(id: 7, name: "Bob")) + try firstCache.handleInsert(rowBytes: rowBytes) + + // Re-registering the same table/type must keep the existing cache instance. + clientCache.registerTable(tableName: "Person", rowType: Person.self) + + let secondCache: TableCache = clientCache.getTableCache(tableName: "Person") + XCTAssertTrue(firstCache === secondCache) + secondCache.sync() + XCTAssertEqual(secondCache.rows.count, 1) + XCTAssertEqual(secondCache.rows[0].id, 7) + XCTAssertEqual(secondCache.rows[0].name, "Bob") + } + + @MainActor + func testTableDeltaCallbacksAndDeregister() throws { + let cache = TableCache(tableName: "Person") + let encoder = BSATNEncoder() + + let oldRow = Person(id: 1, name: "Alice") + let newRow = Person(id: 1, name: "Alicia") + let oldBytes = try encoder.encode(oldRow) + let newBytes = try encoder.encode(newRow) + + let inserts = LockIsolated<[Person]>([]) + let deletes = LockIsolated<[Person]>([]) + let updates = LockIsolated<[(Person, Person)]>([]) + + let insertHandle = cache.onInsert { person in inserts.withValue { $0.append(person) } } + let deleteHandle = cache.onDelete { person in deletes.withValue { $0.append(person) } } + let updateHandle = cache.onUpdate { old, new in + updates.withValue { $0.append((old, new)) } + } + + try cache.handleInsert(rowBytes: oldBytes) + try cache.handleUpdate(oldRowBytes: oldBytes, newRowBytes: newBytes) + try cache.handleDelete(rowBytes: newBytes) + + XCTAssertEqual(inserts.value, [oldRow]) + XCTAssertEqual(deletes.value, [newRow]) + XCTAssertEqual(updates.value.count, 1) + XCTAssertEqual(updates.value[0].0, oldRow) + XCTAssertEqual(updates.value[0].1, newRow) + + insertHandle.cancel() + deleteHandle.cancel() + updateHandle.cancel() + + try cache.handleInsert(rowBytes: oldBytes) + XCTAssertEqual(inserts.value, [oldRow]) + XCTAssertEqual(deletes.value, [newRow]) + XCTAssertEqual(updates.value.count, 1) + } + + @MainActor + func testClientCachePairsDeleteInsertAsUpdateCallback() throws { + let clientCache = ClientCache() + let personCache = TableCache(tableName: "Person") + clientCache.registerTable(name: "Person", cache: personCache) + + let encoder = BSATNEncoder() + let oldRow = Person(id: 42, name: "Alice") + let newRow = Person(id: 42, name: "Alicia") + let oldBytes = try encoder.encode(oldRow) + let newBytes = try encoder.encode(newRow) + + try personCache.handleInsert(rowBytes: oldBytes) + + let updates = LockIsolated<[(Person, Person)]>([]) + let updateHandle = personCache.onUpdate { old, new in + updates.withValue { $0.append((old, new)) } + } + + let update = TransactionUpdate(querySets: [ + QuerySetUpdate( + querySetId: QuerySetId(rawValue: 1), + tables: [ + TableUpdate( + tableName: RawIdentifier(rawValue: "Person"), + rows: [ + .persistentTable( + PersistentTableRows( + inserts: makeRowList(rows: [newBytes]), + deletes: makeRowList(rows: [oldBytes]) + ) + ) + ] + ) + ] + ) + ]) + + clientCache.applyTransactionUpdate(update) + personCache.sync() + + XCTAssertEqual(personCache.rows, [newRow]) + XCTAssertEqual(updates.value.count, 1) + XCTAssertEqual(updates.value[0].0, oldRow) + XCTAssertEqual(updates.value[0].1, newRow) + + updateHandle.cancel() + } + + private func makeRowList(rows: [Data]) -> BsatnRowList { + var rowsData = Data() + var offsets: [UInt64] = [] + offsets.reserveCapacity(rows.count) + for row in rows { + offsets.append(UInt64(rowsData.count)) + rowsData.append(row) + } + return BsatnRowList(sizeHint: .rowOffsets(offsets), rowsData: rowsData) + } +} + +extension Data { + mutating func append(_ value: T) { + var copy = value.littleEndian + self.append(Swift.withUnsafeBytes(of: ©) { Data($0) }) + } +} diff --git a/sdks/swift/Tests/SpacetimeDBTests/KeychainTests.swift b/sdks/swift/Tests/SpacetimeDBTests/KeychainTests.swift new file mode 100644 index 00000000000..a31392194c5 --- /dev/null +++ b/sdks/swift/Tests/SpacetimeDBTests/KeychainTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import SpacetimeDB + +final class KeychainTests: XCTestCase { + + private let store = KeychainTokenStore(service: "com.spacetimedb.test.keychain") + private let testModule = "test-module-\(UUID().uuidString)" + + override func tearDown() { + store.delete(forModule: testModule) + super.tearDown() + } + + func testSaveAndLoad() { + let token = "test-token-\(UUID().uuidString)" + let saved = store.save(token: token, forModule: testModule) + XCTAssertTrue(saved) + + let loaded = store.load(forModule: testModule) + XCTAssertEqual(loaded, token) + } + + func testLoadReturnsNilForMissingKey() { + let loaded = store.load(forModule: "nonexistent-module-\(UUID().uuidString)") + XCTAssertNil(loaded) + } + + func testOverwriteReturnsLatestValue() { + let first = "first-token" + let second = "second-token" + store.save(token: first, forModule: testModule) + store.save(token: second, forModule: testModule) + + let loaded = store.load(forModule: testModule) + XCTAssertEqual(loaded, second) + } + + func testDeleteRemovesToken() { + store.save(token: "ephemeral", forModule: testModule) + let deleted = store.delete(forModule: testModule) + XCTAssertTrue(deleted) + + let loaded = store.load(forModule: testModule) + XCTAssertNil(loaded) + } + + func testDeleteNonexistentKeySucceeds() { + let deleted = store.delete(forModule: "never-existed-\(UUID().uuidString)") + XCTAssertTrue(deleted) + } +} diff --git a/sdks/swift/Tests/SpacetimeDBTests/LiveIntegrationTests.swift b/sdks/swift/Tests/SpacetimeDBTests/LiveIntegrationTests.swift new file mode 100644 index 00000000000..deafbfefef1 --- /dev/null +++ b/sdks/swift/Tests/SpacetimeDBTests/LiveIntegrationTests.swift @@ -0,0 +1,178 @@ +import Foundation +import XCTest +@testable import SpacetimeDB + +final class LiveIntegrationTests: XCTestCase { + private struct LiveConfig { + let serverURL: URL + let moduleName: String + let token: String? + } + + @MainActor + private final class LiveDelegate: SpacetimeClientDelegate { + var didConnect = false + var didDisconnect = false + var connectErrors: [Error] = [] + var reducerErrors: [(reducer: String, message: String, isInternal: Bool)] = [] + + func onConnect() { + didConnect = true + } + + func onDisconnect(error: Error?) { + didDisconnect = true + } + + func onConnectError(error: Error) { + connectErrors.append(error) + } + + func onConnectionStateChange(state: ConnectionState) {} + + func onIdentityReceived(identity: [UInt8], token: String) {} + + func onTransactionUpdate(message: Data?) {} + + func onReducerError(reducer: String, message: String, isInternal: Bool) { + reducerErrors.append((reducer, message, isInternal)) + } + } + + private func requireLiveConfig() throws -> LiveConfig { + let env = ProcessInfo.processInfo.environment + guard env["SPACETIMEDB_SWIFT_LIVE_TESTS"] == "1" else { + throw XCTSkip("Set SPACETIMEDB_SWIFT_LIVE_TESTS=1 to run live integration tests.") + } + + guard let moduleName = env["SPACETIMEDB_LIVE_TEST_DB_NAME"], !moduleName.isEmpty else { + throw XCTSkip("Set SPACETIMEDB_LIVE_TEST_DB_NAME to the published live test database name.") + } + + let urlString = env["SPACETIMEDB_LIVE_TEST_SERVER_URL"] ?? "http://127.0.0.1:3000" + guard let serverURL = URL(string: urlString) else { + throw XCTSkip("Invalid SPACETIMEDB_LIVE_TEST_SERVER_URL: \(urlString)") + } + + return LiveConfig( + serverURL: serverURL, + moduleName: moduleName, + token: env["SPACETIMEDB_LIVE_TEST_TOKEN"] + ) + } + + @MainActor + private func connectClient(using config: LiveConfig) async throws -> (SpacetimeClient, LiveDelegate) { + let delegate = LiveDelegate() + let client = SpacetimeClient(serverUrl: config.serverURL, moduleName: config.moduleName) + client.delegate = delegate + client.connect(token: config.token) + + let connected = await waitUntil(timeoutSeconds: 15.0) { + delegate.didConnect + } + XCTAssertTrue(connected, "Live client failed to connect within timeout.") + XCTAssertTrue(delegate.connectErrors.isEmpty, "Unexpected connect errors: \(delegate.connectErrors)") + XCTAssertEqual(client.connectionState, .connected) + return (client, delegate) + } + + @MainActor + private func waitUntil(timeoutSeconds: TimeInterval, condition: @escaping @MainActor () -> Bool) async -> Bool { + let start = Date() + while Date().timeIntervalSince(start) < timeoutSeconds { + if condition() { + return true + } + try? await Task.sleep(for: .milliseconds(50)) + } + return condition() + } + + @MainActor + func testLiveSubscribeApplyAndUnsubscribe() async throws { + let config = try requireLiveConfig() + let (client, _) = try await connectClient(using: config) + defer { client.disconnect() } + + var applied = false + var subscriptionError: String? + + let handle = client.subscribe( + queries: ["SELECT * FROM person"], + onApplied: { applied = true }, + onError: { message in subscriptionError = message } + ) + + let appliedInTime = await waitUntil(timeoutSeconds: 15.0) { applied } + XCTAssertTrue(appliedInTime) + XCTAssertNil(subscriptionError) + + handle.unsubscribe() + let unsubscribedInTime = await waitUntil(timeoutSeconds: 15.0) { handle.state == .ended } + XCTAssertTrue(unsubscribedInTime) + } + + @MainActor + func testLiveReducerSuccessAndError() async throws { + let config = try requireLiveConfig() + let (client, delegate) = try await connectClient(using: config) + defer { client.disconnect() } + + client.send("say_hello", Data()) + try? await Task.sleep(for: .milliseconds(500)) + XCTAssertTrue(delegate.reducerErrors.isEmpty, "Unexpected reducer errors after say_hello: \(delegate.reducerErrors)") + + client.send("definitely_missing_reducer_live_test", Data()) + let reducerErrorArrived = await waitUntil(timeoutSeconds: 15.0) { !delegate.reducerErrors.isEmpty } + XCTAssertTrue(reducerErrorArrived) + } + + @MainActor + func testLiveProcedureSuccessAndError() async throws { + let config = try requireLiveConfig() + let (client, _) = try await connectClient(using: config) + defer { client.disconnect() } + + var successResult: Result? + client.sendProcedure("sleep_one_second", Data()) { result in + successResult = result + } + let successCallbackArrived = await waitUntil(timeoutSeconds: 20.0) { successResult != nil } + XCTAssertTrue(successCallbackArrived) + if case .failure(let error)? = successResult { + XCTFail("Expected sleep_one_second procedure success, got error: \(error)") + } + + var errorResult: Result? + client.sendProcedure("definitely_missing_procedure_live_test", Data()) { result in + errorResult = result + } + let errorCallbackArrived = await waitUntil(timeoutSeconds: 15.0) { errorResult != nil } + XCTAssertTrue(errorCallbackArrived) + if case .success? = errorResult { + XCTFail("Expected missing procedure to fail.") + } + } + + @MainActor + func testLiveOneOffQuerySuccessAndError() async throws { + let config = try requireLiveConfig() + let (client, _) = try await connectClient(using: config) + defer { client.disconnect() } + + let successRows = try await client.oneOffQuery("SELECT * FROM person") + XCTAssertGreaterThanOrEqual(successRows.tables.count, 0) + + do { + _ = try await client.oneOffQuery("SELECT * FROM definitely_missing_table_live_test") + XCTFail("Expected missing table query to fail.") + } catch { + if case SpacetimeClientQueryError.serverError = error { + // Expected. + } else { + XCTFail("Expected serverError for invalid live query, got: \(error)") + } + } + } +} diff --git a/sdks/swift/Tests/SpacetimeDBTests/LockIsolated.swift b/sdks/swift/Tests/SpacetimeDBTests/LockIsolated.swift new file mode 100644 index 00000000000..0c412fb1c51 --- /dev/null +++ b/sdks/swift/Tests/SpacetimeDBTests/LockIsolated.swift @@ -0,0 +1,15 @@ +import Foundation +import Synchronization + +final class LockIsolated: Sendable { + private let lock: Mutex + init(_ value: T) { + self.lock = Mutex(value) + } + func withValue(_ block: (inout T) -> R) -> R { + return lock.withLock { block(&$0) } + } + var value: T { + return lock.withLock { $0 } + } +} diff --git a/sdks/swift/Tests/SpacetimeDBTests/NetworkTests.swift b/sdks/swift/Tests/SpacetimeDBTests/NetworkTests.swift new file mode 100644 index 00000000000..d4f732ec837 --- /dev/null +++ b/sdks/swift/Tests/SpacetimeDBTests/NetworkTests.swift @@ -0,0 +1,812 @@ +import Compression +import XCTest +import zlib +@testable import SpacetimeDB // This allows us to test internal types + +final class NetworkTests: XCTestCase { + private enum TestError: Error { + case simulated + } + + // We mock the delegate to ensure the client is calling back properly + class MockDelegate: SpacetimeClientDelegate { + var didConnect = false + var didDisconnect = false + var connectErrors: [Error] = [] + var stateChanges: [ConnectionState] = [] + var receivedTransaction = false + var reducerErrorReducer = "" + var reducerErrorMessage = "" + var reducerErrorIsInternal = false + var expectation: XCTestExpectation? + + func onConnect() { + didConnect = true + expectation?.fulfill() + } + + func onDisconnect(error: Error?) { + didDisconnect = true + expectation?.fulfill() + } + + func onConnectError(error: Error) { + connectErrors.append(error) + } + + func onConnectionStateChange(state: ConnectionState) { + stateChanges.append(state) + } + + func onIdentityReceived(identity: [UInt8], token: String) {} + + func onTransactionUpdate(message: Data?) { + receivedTransaction = true + expectation?.fulfill() + } + + func onReducerError(reducer: String, message: String, isInternal: Bool) { + reducerErrorReducer = reducer + reducerErrorMessage = message + reducerErrorIsInternal = isInternal + expectation?.fulfill() + } + } + + @MainActor + func testClientInitialization() { + let url = URL(string: "http://localhost:3000")! + let client = SpacetimeClient(serverUrl: url, moduleName: "test-module") + XCTAssertEqual(client.serverUrl, url) + XCTAssertEqual(client.moduleName, "test-module") + XCTAssertEqual(client.connectionState, .disconnected) + } + + @MainActor + func testInitialConnectionTransitionsToConnectedAndNotifiesState() { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + let delegate = MockDelegate() + client.delegate = delegate + let initial = InitialConnection( + identity: Identity(rawBytes: Data(repeating: 0xAB, count: 32)), + connectionId: ClientConnectionId(rawBytes: Data(repeating: 0xCD, count: 16)), + token: "token" + ) + client._test_deliverServerMessage(.initialConnection(initial)) + + XCTAssertEqual(client.connectionState, .connected) + XCTAssertTrue(delegate.didConnect) + XCTAssertEqual(delegate.stateChanges, [.connected]) + } + + @MainActor + func testConnectingFailureTriggersConnectErrorCallback() { + let policy = ReconnectPolicy(maxRetries: 0, initialDelaySeconds: 0.1, maxDelaySeconds: 0.1, multiplier: 1, jitterRatio: 0) + let client = SpacetimeClient( + serverUrl: URL(string: "http://localhost:3000")!, + moduleName: "test-module", + reconnectPolicy: policy + ) + let delegate = MockDelegate() + client.delegate = delegate + + client._test_setConnectionState(.connecting) + client._test_simulateConnectionFailure(TestError.simulated) + + XCTAssertEqual(client.connectionState, .disconnected) + XCTAssertEqual(delegate.connectErrors.count, 1) + XCTAssertTrue(delegate.didDisconnect) + } + + @MainActor + func testConnectedFailureDoesNotTriggerConnectErrorCallback() { + let policy = ReconnectPolicy(maxRetries: 0, initialDelaySeconds: 0.1, maxDelaySeconds: 0.1, multiplier: 1, jitterRatio: 0) + let client = SpacetimeClient( + serverUrl: URL(string: "http://localhost:3000")!, + moduleName: "test-module", + reconnectPolicy: policy + ) + let delegate = MockDelegate() + client.delegate = delegate + let initial = InitialConnection( + identity: Identity(rawBytes: Data(repeating: 0xAB, count: 32)), + connectionId: ClientConnectionId(rawBytes: Data(repeating: 0xCD, count: 16)), + token: "token" + ) + client._test_deliverServerMessage(.initialConnection(initial)) + client._test_simulateConnectionFailure(TestError.simulated) + + XCTAssertEqual(client.connectionState, .disconnected) + XCTAssertEqual(delegate.connectErrors.count, 0) + XCTAssertTrue(delegate.didDisconnect) + } + + func testCompressionModeQueryValues() { + XCTAssertEqual(CompressionMode.none.queryValue, "None") + XCTAssertEqual(CompressionMode.gzip.queryValue, "Gzip") + XCTAssertEqual(CompressionMode.brotli.queryValue, "Brotli") + } + + func testReconnectPolicyBackoffWithoutJitter() { + let policy = ReconnectPolicy( + maxRetries: 4, + initialDelaySeconds: 0.5, + maxDelaySeconds: 10, + multiplier: 2.0, + jitterRatio: 0 + ) + + XCTAssertEqual(policy.delaySeconds(forAttempt: 1, randomUnit: 0.5) ?? -1, 0.5, accuracy: 0.0001) + XCTAssertEqual(policy.delaySeconds(forAttempt: 2, randomUnit: 0.5) ?? -1, 1.0, accuracy: 0.0001) + XCTAssertEqual(policy.delaySeconds(forAttempt: 3, randomUnit: 0.5) ?? -1, 2.0, accuracy: 0.0001) + XCTAssertEqual(policy.delaySeconds(forAttempt: 4, randomUnit: 0.5) ?? -1, 4.0, accuracy: 0.0001) + XCTAssertNil(policy.delaySeconds(forAttempt: 5, randomUnit: 0.5)) + } + + func testReconnectPolicyRespectsJitterBounds() { + let policy = ReconnectPolicy( + maxRetries: 1, + initialDelaySeconds: 10.0, + maxDelaySeconds: 10.0, + multiplier: 2.0, + jitterRatio: 0.2 + ) + + let minDelay = policy.delaySeconds(forAttempt: 1, randomUnit: 0.0) ?? -1 + let maxDelay = policy.delaySeconds(forAttempt: 1, randomUnit: 1.0) ?? -1 + + XCTAssertEqual(minDelay, 8.0, accuracy: 0.0001) + XCTAssertEqual(maxDelay, 12.0, accuracy: 0.0001) + } + + func testSubscriptionMessageEncoding() throws { + // Just verify our protocol messages compile and encode with BSATN successfully + let subscribe = ClientMessage.subscribe(Subscribe(queryStrings: ["SELECT * FROM person"], requestId: RequestId(rawValue: 1))) + let encoder = BSATNEncoder() + + let data = try encoder.encode(subscribe) + // Message is an enum. Tag 0 for subscribe. + XCTAssertGreaterThan(data.count, 0) + } + + func testProcedureMessageEncoding() throws { + let call = ClientMessage.callProcedure( + CallProcedure(requestId: RequestId(rawValue: 42), flags: 0, procedure: "say_hello", args: Data([0x01, 0x02])) + ) + let data = try BSATNEncoder().encode(call) + + XCTAssertGreaterThan(data.count, 0) + XCTAssertEqual(data.first, 4) // v2 ClientMessage::CallProcedure tag + } + + func testUnsubscribeMessageEncoding() throws { + let msg = ClientMessage.unsubscribe(Unsubscribe(requestId: RequestId(rawValue: 1), querySetId: QuerySetId(rawValue: 7), flags: 1)) + let data = try BSATNEncoder().encode(msg) + XCTAssertGreaterThan(data.count, 0) + XCTAssertEqual(data.first, 1) // v2 ClientMessage::Unsubscribe tag + } + + func testUnsubscribeAppliedOptionTagDecoding() throws { + var somePayload = Data() + appendLE(1 as UInt32, to: &somePayload) // request_id + appendLE(77 as UInt32, to: &somePayload) // query_set_id + appendLE(0 as UInt8, to: &somePayload) // rows: Some + appendLE(0 as UInt32, to: &somePayload) // QueryRows.tables count + + var someServerMessage = Data([2]) // ServerMessage::UnsubscribeApplied + someServerMessage.append(somePayload) + let decodedSomeMessage = try BSATNDecoder().decode(ServerMessage.self, from: someServerMessage) + guard case .unsubscribeApplied(let decodedSome) = decodedSomeMessage else { + return XCTFail("Expected unsubscribeApplied message") + } + XCTAssertEqual(decodedSome.requestId, RequestId(rawValue: 1)) + XCTAssertEqual(decodedSome.querySetId, QuerySetId(rawValue: 77)) + XCTAssertEqual(decodedSome.rows?.tables.count, 0) + + var nonePayload = Data() + appendLE(1 as UInt32, to: &nonePayload) // request_id + appendLE(77 as UInt32, to: &nonePayload) // query_set_id + appendLE(1 as UInt8, to: &nonePayload) // rows: None + + var noneServerMessage = Data([2]) // ServerMessage::UnsubscribeApplied + noneServerMessage.append(nonePayload) + let decodedNoneMessage = try BSATNDecoder().decode(ServerMessage.self, from: noneServerMessage) + guard case .unsubscribeApplied(let decodedNone) = decodedNoneMessage else { + return XCTFail("Expected unsubscribeApplied message") + } + XCTAssertNil(decodedNone.rows) + + var invalidPayload = Data() + appendLE(1 as UInt32, to: &invalidPayload) + appendLE(77 as UInt32, to: &invalidPayload) + appendLE(2 as UInt8, to: &invalidPayload) // invalid option tag + var invalidServerMessage = Data([2]) // ServerMessage::UnsubscribeApplied + invalidServerMessage.append(invalidPayload) + XCTAssertThrowsError(try BSATNDecoder().decode(ServerMessage.self, from: invalidServerMessage)) + } + + func testSubscriptionErrorOptionTagDecoding() throws { + let messageBytes = try BSATNEncoder().encode("bad query") + + var somePayload = Data() + appendLE(0 as UInt8, to: &somePayload) // request_id: Some + appendLE(9 as UInt32, to: &somePayload) // request_id value + appendLE(42 as UInt32, to: &somePayload) // query_set_id + somePayload.append(messageBytes) + + var someServerMessage = Data([3]) // ServerMessage::SubscriptionError + someServerMessage.append(somePayload) + let decodedSomeMessage = try BSATNDecoder().decode(ServerMessage.self, from: someServerMessage) + guard case .subscriptionError(let decodedSome) = decodedSomeMessage else { + return XCTFail("Expected subscriptionError message") + } + XCTAssertEqual(decodedSome.requestId, RequestId(rawValue: 9)) + XCTAssertEqual(decodedSome.querySetId, QuerySetId(rawValue: 42)) + XCTAssertEqual(decodedSome.error, "bad query") + + var nonePayload = Data() + appendLE(1 as UInt8, to: &nonePayload) // request_id: None + appendLE(42 as UInt32, to: &nonePayload) // query_set_id + nonePayload.append(messageBytes) + + var noneServerMessage = Data([3]) // ServerMessage::SubscriptionError + noneServerMessage.append(nonePayload) + let decodedNoneMessage = try BSATNDecoder().decode(ServerMessage.self, from: noneServerMessage) + guard case .subscriptionError(let decodedNone) = decodedNoneMessage else { + return XCTFail("Expected subscriptionError message") + } + XCTAssertNil(decodedNone.requestId) + XCTAssertEqual(decodedNone.querySetId, QuerySetId(rawValue: 42)) + XCTAssertEqual(decodedNone.error, "bad query") + + var invalidPayload = Data() + appendLE(2 as UInt8, to: &invalidPayload) // invalid option tag + appendLE(42 as UInt32, to: &invalidPayload) + invalidPayload.append(messageBytes) + var invalidServerMessage = Data([3]) // ServerMessage::SubscriptionError + invalidServerMessage.append(invalidPayload) + XCTAssertThrowsError(try BSATNDecoder().decode(ServerMessage.self, from: invalidServerMessage)) + } + + func testOneOffQueryMessageEncoding() throws { + let msg = ClientMessage.oneOffQuery(OneOffQuery(requestId: RequestId(rawValue: 1), queryString: "SELECT * FROM player")) + let data = try BSATNEncoder().encode(msg) + XCTAssertGreaterThan(data.count, 0) + XCTAssertEqual(data.first, 2) // v2 ClientMessage::OneOffQuery tag + } + + @MainActor + func testOneOffQueryCallbackSuccessAndError() throws { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + let success = XCTestExpectation(description: "One-off query success callback") + let failure = XCTestExpectation(description: "One-off query error callback") + + client.oneOffQuery("SELECT * FROM player") { result in + switch result { + case .success(let rows): + XCTAssertEqual(rows.tables.count, 0) + success.fulfill() + case .failure(let error): + XCTFail("Unexpected one-off query failure: \(error)") + } + } + + client.handleOneOffQueryResult( + OneOffQueryResult(requestId: RequestId(rawValue: 1), result: .ok(QueryRows(tables: []))) + ) + + client.oneOffQuery("SELECT * FROM nope") { result in + switch result { + case .success: + XCTFail("Expected one-off query error") + case .failure(let error): + XCTAssertEqual(error as? SpacetimeClientQueryError, .serverError("bad query")) + failure.fulfill() + } + } + + client.handleOneOffQueryResult( + OneOffQueryResult(requestId: RequestId(rawValue: 2), result: .err("bad query")) + ) + + wait(for: [success, failure], timeout: 1.0) + } + + @MainActor + func testManagedSubscriptionLifecycle() throws { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + let onApplied = XCTestExpectation(description: "Managed subscription becomes active") + + let handle = client.subscribe(queries: ["SELECT * FROM player"], onApplied: { + onApplied.fulfill() + }) + + XCTAssertEqual(handle.state, .pending) + + let applied = SubscribeApplied(requestId: RequestId(rawValue: 1), querySetId: QuerySetId(rawValue: 77), rows: QueryRows(tables: [])) + client.handleSubscribeApplied(applied) + + wait(for: [onApplied], timeout: 1.0) + XCTAssertEqual(handle.state, .active) + + client.unsubscribe(handle) + let unapplied = UnsubscribeApplied(requestId: RequestId(rawValue: 2), querySetId: QuerySetId(rawValue: 77), rows: nil) + client.handleUnsubscribeApplied(unapplied) + + XCTAssertEqual(handle.state, .ended) + } + + @MainActor + func testSubscriptionErrorEndsHandleAndCallsErrorCallback() { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + let onError = XCTestExpectation(description: "Managed subscription error callback called") + var received = "" + + let handle = client.subscribe( + queries: ["SELECT * FROM invalid_table"], + onError: { message in + received = message + onError.fulfill() + } + ) + + client.handleSubscriptionError( + SubscriptionError(requestId: RequestId(rawValue: 1), querySetId: QuerySetId(rawValue: 1), error: "bad query syntax") + ) + + wait(for: [onError], timeout: 1.0) + XCTAssertEqual(handle.state, .ended) + XCTAssertEqual(received, "bad query syntax") + } + + @MainActor + func testUnsubscribeIsIdempotentAfterEnd() { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + let handle = client.subscribe(queries: ["SELECT * FROM player"]) + + client.handleSubscribeApplied(SubscribeApplied(requestId: RequestId(rawValue: 1), querySetId: QuerySetId(rawValue: 9), rows: QueryRows(tables: []))) + XCTAssertEqual(handle.state, .active) + + client.unsubscribe(handle) + client.handleUnsubscribeApplied(UnsubscribeApplied(requestId: RequestId(rawValue: 2), querySetId: QuerySetId(rawValue: 9), rows: nil)) + XCTAssertEqual(handle.state, .ended) + + // A second call should no-op and stay stable. + client.unsubscribe(handle) + XCTAssertEqual(handle.state, .ended) + } + + @MainActor + func testDisconnectFailsPendingOneOffQueryCallbacks() { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + let failure = XCTestExpectation(description: "Pending one-off query fails on disconnect") + + client.oneOffQuery("SELECT * FROM player") { result in + switch result { + case .success: + XCTFail("Expected disconnect failure for pending one-off query") + case .failure(let error): + XCTAssertEqual(error as? SpacetimeClientQueryError, .disconnected) + failure.fulfill() + } + } + + client.disconnect() + wait(for: [failure], timeout: 1.0) + } + + @MainActor + func testOneOffQueryAsyncTimeout() async { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + + do { + _ = try await client.oneOffQuery("SELECT * FROM player", timeout: .milliseconds(10)) + XCTFail("Expected async one-off query timeout.") + } catch { + XCTAssertEqual(error as? SpacetimeClientQueryError, .timeout) + } + } + + @MainActor + func testOneOffQueryAsyncCancellationClearsPendingCallback() async { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + + let task = Task { + try await client.oneOffQuery("SELECT * FROM player", timeout: .seconds(5)) + } + + await Task.yield() + task.cancel() + + do { + _ = try await task.value + XCTFail("Expected cancellation to throw.") + } catch is CancellationError { + // expected + } catch { + XCTFail("Expected CancellationError, got: \(error)") + } + + XCTAssertEqual(client._test_pendingOneOffQueryCallbackCount(), 0) + } + + @MainActor + func testProcedureCallbackSuccess() throws { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + let expectation = XCTestExpectation(description: "Procedure callback receives decoded return value") + + client.sendProcedure("say_hello", Data(), responseType: String.self) { result in + switch result { + case .success(let value): + XCTAssertEqual(value, "hello") + expectation.fulfill() + case .failure(let error): + XCTFail("Unexpected failure: \(error)") + } + } + + let returned = try BSATNEncoder().encode("hello") + let procedureResult = ProcedureResult( + status: .returned(returned), + timestamp: 0, + totalHostExecutionDuration: 0, + requestId: RequestId(rawValue: 1) + ) + client.handleProcedureResult(procedureResult) + + wait(for: [expectation], timeout: 1.0) + } + + @MainActor + func testProcedureAsyncRawReturn() async throws { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + let expectedData = Data([0x01, 0x02, 0x03]) + + let task = Task { + try await client.sendProcedure("raw_echo", Data()) + } + + await Task.yield() + client.handleProcedureResult( + ProcedureResult( + status: .returned(expectedData), + timestamp: 0, + totalHostExecutionDuration: 0, + requestId: RequestId(rawValue: 1) + ) + ) + + let returned = try await task.value + XCTAssertEqual(returned, expectedData) + } + + @MainActor + func testProcedureAsyncTimeout() async { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + + do { + _ = try await client.sendProcedure("slow", Data(), timeout: .milliseconds(10)) + XCTFail("Expected async procedure timeout.") + } catch { + XCTAssertEqual(error as? SpacetimeClientProcedureError, .timeout) + } + } + + @MainActor + func testProcedureAsyncCancellationClearsPendingCallback() async { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + + let task = Task { + try await client.sendProcedure("slow", Data(), timeout: .seconds(5)) + } + + await Task.yield() + task.cancel() + + do { + _ = try await task.value + XCTFail("Expected cancellation to throw.") + } catch is CancellationError { + // expected + } catch { + XCTFail("Expected CancellationError, got: \(error)") + } + + XCTAssertEqual(client._test_pendingProcedureCallbackCount(), 0) + } + + @MainActor + func testProcedureAsyncDecodedReturn() async throws { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + let encoded = try BSATNEncoder().encode("hello") + + let task = Task { + try await client.sendProcedure("say_hello", Data(), responseType: String.self) + } + + await Task.yield() + client.handleProcedureResult( + ProcedureResult( + status: .returned(encoded), + timestamp: 0, + totalHostExecutionDuration: 0, + requestId: RequestId(rawValue: 1) + ) + ) + + let value = try await task.value + XCTAssertEqual(value, "hello") + } + + @MainActor + func testProcedureAsyncDecodedReturnWithTimeoutParameter() async throws { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + let encoded = try BSATNEncoder().encode("hello") + + let task = Task { + try await client.sendProcedure( + "say_hello", + Data(), + responseType: String.self, + timeout: .seconds(2) + ) + } + + await Task.yield() + client.handleProcedureResult( + ProcedureResult( + status: .returned(encoded), + timestamp: 0, + totalHostExecutionDuration: 0, + requestId: RequestId(rawValue: 1) + ) + ) + + let value = try await task.value + XCTAssertEqual(value, "hello") + } + + @MainActor + func testProcedureCallbackInternalError() throws { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + let expectation = XCTestExpectation(description: "Procedure callback receives internal error") + + client.sendProcedure("say_hello", Data(), responseType: String.self) { result in + switch result { + case .success(let value): + XCTFail("Unexpected success: \(value)") + case .failure(let error): + guard case SpacetimeClientProcedureError.internalError(let message) = error else { + XCTFail("Unexpected error type: \(error)") + return + } + XCTAssertEqual(message, "boom") + expectation.fulfill() + } + } + + let procedureResult = ProcedureResult( + status: .internalError("boom"), + timestamp: 0, + totalHostExecutionDuration: 0, + requestId: RequestId(rawValue: 1) + ) + client.handleProcedureResult(procedureResult) + + wait(for: [expectation], timeout: 1.0) + } + + @MainActor + func testReducerInternalErrorCallbackIncludesReducerName() { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + let delegate = MockDelegate() + let expectation = XCTestExpectation(description: "Reducer internal error callback receives reducer name") + delegate.expectation = expectation + client.delegate = delegate + + // Registers request_id=1 -> "add" in pending reducer map. + client.send("add", Data()) + + client.handleReducerResult( + ReducerResult(requestId: RequestId(rawValue: 1), timestamp: 0, result: .internalError("no such reducer")) + ) + + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(delegate.reducerErrorReducer, "add") + XCTAssertEqual(delegate.reducerErrorMessage, "no such reducer") + XCTAssertTrue(delegate.reducerErrorIsInternal) + } + + @MainActor + func testReducerErrPayloadFallsBackToUTF8AndUnknownReducer() { + let client = SpacetimeClient(serverUrl: URL(string: "http://localhost:3000")!, moduleName: "test-module") + let delegate = MockDelegate() + let expectation = XCTestExpectation(description: "Reducer err payload reports utf8 message") + delegate.expectation = expectation + client.delegate = delegate + + client.handleReducerResult( + ReducerResult(requestId: RequestId(rawValue: 4242), timestamp: 0, result: .err(Data("plain utf8 error".utf8))) + ) + + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(delegate.reducerErrorReducer, "") + XCTAssertEqual(delegate.reducerErrorMessage, "plain utf8 error") + XCTAssertFalse(delegate.reducerErrorIsInternal) + } + + func testFrameDecoderNoneCompression() throws { + let payload = Data("hello-spacetimedb".utf8) + var frame = Data([0]) + frame.append(payload) + let decoded = try ServerMessageFrameDecoder.decodePayload(from: frame) + XCTAssertEqual(decoded, payload) + } + + func testFrameDecoderBrotliCompression() throws { + let payload = Data(repeating: 0x2A, count: 4096) + let compressed = try compressWithCompressionStream(payload, algorithm: COMPRESSION_BROTLI) + var frame = Data([1]) + frame.append(compressed) + let decoded = try ServerMessageFrameDecoder.decodePayload(from: frame) + XCTAssertEqual(decoded, payload) + } + + func testFrameDecoderGzipCompression() throws { + let payload = Data(repeating: 0x7F, count: 4096) + let compressed = try compressGzip(payload) + var frame = Data([2]) + frame.append(compressed) + let decoded = try ServerMessageFrameDecoder.decodePayload(from: frame) + XCTAssertEqual(decoded, payload) + } + + func testFrameDecoderUnsupportedCompressionTag() { + let frame = Data([99, 0x00, 0x01]) + + do { + _ = try ServerMessageFrameDecoder.decodePayload(from: frame) + XCTFail("Expected unsupported compression error") + } catch let error as ServerMessageFrameDecodingError { + guard case .unsupportedCompression(let tag) = error else { + XCTFail("Unexpected frame decoder error: \(error)") + return + } + XCTAssertEqual(tag, 99) + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + + private func compressWithCompressionStream(_ payload: Data, algorithm: compression_algorithm) throws -> Data { + enum CompressionError: Error { + case initializationFailed + case compressionFailed + } + + if payload.isEmpty { + return Data() + } + + let destinationBufferSize = 64 * 1024 + let bootstrapPtr = UnsafeMutablePointer.allocate(capacity: 1) + defer { bootstrapPtr.deallocate() } + var stream = compression_stream( + dst_ptr: bootstrapPtr, + dst_size: 0, + src_ptr: UnsafePointer(bootstrapPtr), + src_size: 0, + state: nil + ) + let initStatus = compression_stream_init(&stream, COMPRESSION_STREAM_ENCODE, algorithm) + guard initStatus != COMPRESSION_STATUS_ERROR else { + throw CompressionError.initializationFailed + } + defer { compression_stream_destroy(&stream) } + + return try payload.withUnsafeBytes { rawBuffer in + guard let srcBase = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { + return Data() + } + + stream.src_ptr = srcBase + stream.src_size = payload.count + + let destinationBuffer = UnsafeMutablePointer.allocate(capacity: destinationBufferSize) + defer { destinationBuffer.deallocate() } + + var output = Data() + while true { + stream.dst_ptr = destinationBuffer + stream.dst_size = destinationBufferSize + + let status = compression_stream_process(&stream, Int32(COMPRESSION_STREAM_FINALIZE.rawValue)) + let produced = destinationBufferSize - stream.dst_size + if produced > 0 { + output.append(destinationBuffer, count: produced) + } + + switch status { + case COMPRESSION_STATUS_OK: + continue + case COMPRESSION_STATUS_END: + return output + default: + throw CompressionError.compressionFailed + } + } + } + } + + private func compressGzip(_ payload: Data) throws -> Data { + enum GzipError: Error { + case invalidInputSize + case initializationFailed + case compressionFailed + } + + if payload.isEmpty { + return Data() + } + + guard payload.count <= Int(UInt32.max) else { + throw GzipError.invalidInputSize + } + + return try payload.withUnsafeBytes { rawBuffer in + guard let srcBase = rawBuffer.bindMemory(to: Bytef.self).baseAddress else { + return Data() + } + + var stream = z_stream() + stream.next_in = UnsafeMutablePointer(mutating: srcBase) + stream.avail_in = uInt(payload.count) + + let initStatus = deflateInit2_( + &stream, + Z_BEST_SPEED, + Z_DEFLATED, + 31, // gzip wrapper + 8, + Z_DEFAULT_STRATEGY, + ZLIB_VERSION, + Int32(MemoryLayout.size) + ) + guard initStatus == Z_OK else { + throw GzipError.initializationFailed + } + defer { deflateEnd(&stream) } + + let destinationBufferSize = 64 * 1024 + var destinationBuffer = [UInt8](repeating: 0, count: destinationBufferSize) + var output = Data() + + while true { + let deflateStatus: Int32 = destinationBuffer.withUnsafeMutableBytes { outRaw in + stream.next_out = outRaw.bindMemory(to: Bytef.self).baseAddress + stream.avail_out = uInt(destinationBufferSize) + return deflate(&stream, Z_FINISH) + } + + let produced = destinationBufferSize - Int(stream.avail_out) + if produced > 0 { + output.append(contentsOf: destinationBuffer[0..(_ value: T, to data: inout Data) { + var littleEndian = value.littleEndian + data.append(Swift.withUnsafeBytes(of: &littleEndian) { Data($0) }) + } +} diff --git a/sdks/swift/Tests/SpacetimeDBTests/ObservabilityTests.swift b/sdks/swift/Tests/SpacetimeDBTests/ObservabilityTests.swift new file mode 100644 index 00000000000..0e625c0d3b3 --- /dev/null +++ b/sdks/swift/Tests/SpacetimeDBTests/ObservabilityTests.swift @@ -0,0 +1,163 @@ +import Foundation +import XCTest +@testable import SpacetimeDB + +final class ObservabilityTests: XCTestCase { + private final class CapturingLogger: @unchecked Sendable, SpacetimeLogger { + struct Entry: Sendable { + let level: SpacetimeLogLevel + let category: String + let message: String + } + + private let lock = NSLock() + private var entries: [Entry] = [] + + func log(level: SpacetimeLogLevel, category: String, message: String) { + lock.lock() + entries.append(Entry(level: level, category: category, message: message)) + lock.unlock() + } + + func snapshot() -> [Entry] { + lock.lock() + defer { lock.unlock() } + return entries + } + } + + private final class CapturingMetrics: @unchecked Sendable, SpacetimeMetrics { + struct Gauge: Sendable { + let name: String + let value: Double + let tags: [String: String] + } + + struct Timing: Sendable { + let name: String + let milliseconds: Double + let tags: [String: String] + } + + private let lock = NSLock() + private var counters: [String: Int64] = [:] + private var gauges: [Gauge] = [] + private var timings: [Timing] = [] + + func incrementCounter(_ name: String, by value: Int64, tags: [String: String]) { + lock.lock() + counters[name, default: 0] += value + lock.unlock() + } + + func recordGauge(_ name: String, value: Double, tags: [String: String]) { + lock.lock() + gauges.append(Gauge(name: name, value: value, tags: tags)) + lock.unlock() + } + + func recordTiming(_ name: String, milliseconds: Double, tags: [String: String]) { + lock.lock() + timings.append(Timing(name: name, milliseconds: milliseconds, tags: tags)) + lock.unlock() + } + + func counterValue(_ name: String) -> Int64 { + lock.lock() + defer { lock.unlock() } + return counters[name, default: 0] + } + + func hasGauge(named name: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return gauges.contains(where: { $0.name == name }) + } + + func hasTiming(named name: String, callback: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return timings.contains(where: { $0.name == name && $0.tags["callback"] == callback }) + } + } + + private struct Person: Codable, Sendable { + var id: UInt32 + var name: String + } + + private enum TestError: Error { + case simulated + } + + @MainActor + private final class DelegateProbe: SpacetimeClientDelegate { + func onConnect() {} + func onDisconnect(error: Error?) {} + func onConnectError(error: Error) {} + func onConnectionStateChange(state: ConnectionState) {} + func onIdentityReceived(identity: [UInt8], token: String) {} + func onTransactionUpdate(message: Data?) {} + func onReducerError(reducer: String, message: String, isInternal: Bool) {} + } + + @MainActor + func testCustomLoggerReceivesSDKLogEntries() { + let oldLogger = SpacetimeObservability.logger + defer { SpacetimeObservability.logger = oldLogger } + + let logger = CapturingLogger() + SpacetimeObservability.logger = logger + + let cache = TableCache(tableName: "Person") + XCTAssertThrowsError(try cache.handleInsert(rowBytes: Data([0xFF]))) + + let entries = logger.snapshot() + XCTAssertTrue(entries.contains(where: { $0.category == "Cache" && $0.level == .error })) + } + + @MainActor + func testCustomMetricsCaptureClientCountersAndGauges() { + let oldMetrics = SpacetimeObservability.metrics + defer { SpacetimeObservability.metrics = oldMetrics } + + let metrics = CapturingMetrics() + SpacetimeObservability.metrics = metrics + + let policy = ReconnectPolicy( + maxRetries: 0, + initialDelaySeconds: 0.1, + maxDelaySeconds: 0.1, + multiplier: 1.0, + jitterRatio: 0 + ) + let client = SpacetimeClient( + serverUrl: URL(string: "http://localhost:3000")!, + moduleName: "test-module", + reconnectPolicy: policy + ) + let delegate = DelegateProbe() + client.delegate = delegate + + client.sendProcedure("bench_proc", Data()) { _ in } + client._test_deliverServerMessage( + .procedureResult( + ProcedureResult( + status: .returned(Data([0x01])), + timestamp: 0, + totalHostExecutionDuration: 0, + requestId: RequestId(rawValue: 1) + ) + ) + ) + client._test_setConnectionState(.connecting) + client._test_simulateConnectionFailure(TestError.simulated) + + XCTAssertGreaterThanOrEqual(metrics.counterValue("spacetimedb.connection.failures"), 1) + XCTAssertTrue(metrics.hasGauge(named: "spacetimedb.connection.state")) + XCTAssertTrue(metrics.hasTiming(named: "spacetimedb.callback.latency", callback: "procedure.completion")) + XCTAssertTrue(metrics.hasTiming(named: "spacetimedb.callback.latency", callback: "delegate.on_connection_state_change")) + XCTAssertTrue(metrics.hasTiming(named: "spacetimedb.callback.latency", callback: "delegate.on_connect_error")) + XCTAssertTrue(metrics.hasTiming(named: "spacetimedb.callback.latency", callback: "delegate.on_disconnect")) + } +} diff --git a/sdks/swift/Tests/SpacetimeDBTests/ProtocolParityTests.swift b/sdks/swift/Tests/SpacetimeDBTests/ProtocolParityTests.swift new file mode 100644 index 00000000000..86f907d635f --- /dev/null +++ b/sdks/swift/Tests/SpacetimeDBTests/ProtocolParityTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import SpacetimeDB + +final class ProtocolParityTests: XCTestCase { + + private let encoder = BSATNEncoder() + private let decoder = BSATNDecoder() + + func testSubscribeEncodingParity() throws { + let msg = ClientMessage.subscribe(Subscribe( + queryStrings: ["SELECT * FROM player"], + requestId: RequestId(rawValue: 1), + querySetId: QuerySetId(rawValue: 1) + )) + + let encoded = try encoder.encode(msg) + let hex = encoded.map { String(format: "%02x", $0) }.joined() + + // Tag 0 (Subscribe) + // RequestId 1: 01000000 + // QuerySetId 1: 01000000 + // Array Len 1: 01000000 + // String Len 20: 14000000 + // String "SELECT * FROM player" + let expectedHex = "000100000001000000010000001400000053454c454354202a2046524f4d20706c61796572" + XCTAssertEqual(hex, expectedHex) + } + + func testCallReducerEncodingParity() throws { + let args = Data([0xDE, 0xAD, 0xBE, 0xEF]) + let msg = ClientMessage.callReducer(CallReducer( + requestId: RequestId(rawValue: 42), + flags: 0, + reducer: "move", + args: args + )) + + let encoded = try encoder.encode(msg) + let hex = encoded.map { String(format: "%02x", $0) }.joined() + + // Tag 3 (CallReducer) + // RequestId 42: 2a000000 + // Flags 0: 00 + // Reducer name "move" (len 4: 04000000, bytes: 6d6f7665) + // Args len 4: 04000000, bytes: deadbeef + let expectedHex = "032a00000000040000006d6f766504000000deadbeef" + XCTAssertEqual(hex, expectedHex) + } + + func testInitialConnectionDecodingParity() throws { + // Tag 0 (InitialConnection) + // Identity (32 bytes zeros) + // ConnectionId (16 bytes, let's say 1...) + // Token len 5: 05000000, "hello" + var hex = "00" // tag + hex += String(repeating: "00", count: 32) // identity + hex += "01000000000000000000000000000000" // connection id + hex += "0500000068656c6c6f" // token + + let data = Data(hexString: hex) + let msg = try decoder.decode(ServerMessage.self, from: data) + + if case .initialConnection(let conn) = msg { + XCTAssertEqual(conn.token, "hello") + XCTAssertEqual(conn.identity.rawBytes.count, 32) + XCTAssertEqual(conn.connectionId.rawBytes[0], 1) + } else { + XCTFail("Wrong message type: \(msg)") + } + } +} + +extension Data { + init(hexString: String) { + var data = Data() + var hex = hexString + while hex.count > 0 { + let subIndex = hex.index(hex.startIndex, offsetBy: 2) + let c = String(hex[..(0) + private let errors = ManagedAtomic(0) + + func recordApplied() { + applied.wrappingIncrement(ordering: .relaxed) + } + + func recordApplied(count: UInt64) { + applied.wrappingIncrement(by: count, ordering: .relaxed) + } + + func recordError() { + errors.wrappingIncrement(ordering: .relaxed) + } + + func snapshot() -> (applied: UInt64, errors: UInt64) { + ( + applied.load(ordering: .relaxed), + errors.load(ordering: .relaxed) + ) + } +} + +@MainActor +final class BenchDelegate: SpacetimeClientDelegate { + let counter: CompletionCounter + + init(counter: CompletionCounter) { + self.counter = counter + } + + func onConnect() {} + func onDisconnect(error _: Error?) {} + func onConnectError(error _: Error) {} + func onConnectionStateChange(state _: ConnectionState) {} + func onIdentityReceived(identity _: [UInt8], token _: String) {} + + func onTransactionUpdate(message _: Data?) { + // Completion accounting uses raw transaction observer callback. + } + + func onReducerError(reducer _: String, message _: String, isInternal _: Bool) { + counter.recordError() + } +} + +struct SplitMix64: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { + state = seed + } + + mutating func next() -> UInt64 { + state &+= 0x9E3779B97F4A7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 + z = (z ^ (z >> 27)) &* 0x94D049BB133111EB + return z ^ (z >> 31) + } + + mutating func nextUnitInterval() -> Double { + Double(next()) / Double(UInt64.max) + } +} + +struct ZipfSampler { + private let cdf: [Double] + + init(accountCount: UInt32, alpha: Double) { + let n = Int(accountCount) + var weights = Array(repeating: 0.0, count: n) + var sum = 0.0 + for i in 0.. UInt32 { + let needle = rng.nextUnitInterval() + var lo = 0 + var hi = cdf.count - 1 + + while lo < hi { + let mid = (lo + hi) / 2 + if needle <= cdf[mid] { + hi = mid + } else { + lo = mid + 1 + } + } + + return UInt32(lo + 1) // 1-based, matching Rust-client sampling behavior. + } +} + +func makeTransfers(accounts: UInt32, alpha: Double, count: Int = 10_000_000) -> [(UInt32, UInt32)] { + let sampler = ZipfSampler(accountCount: accounts, alpha: alpha) + var rng = SplitMix64(seed: 0x1234_5678) + var transfers: [(UInt32, UInt32)] = [] + transfers.reserveCapacity(count) + + while transfers.count < count { + let from = sampler.sample(using: &rng) + let to = sampler.sample(using: &rng) + if from >= accounts || to >= accounts || from == to { + continue + } + transfers.append((from, to)) + } + + return transfers +} + +func parseDurationSeconds(_ raw: String) throws -> Double { + let lowered = raw.lowercased() + if lowered.hasSuffix("ms") { + let value = String(lowered.dropLast(2)) + guard let ms = Double(value), ms > 0 else { throw CliError.invalidDuration(raw) } + return ms / 1000 + } + if lowered.hasSuffix("s") { + let value = String(lowered.dropLast()) + guard let seconds = Double(value), seconds > 0 else { throw CliError.invalidDuration(raw) } + return seconds + } + if lowered.hasSuffix("m") { + let value = String(lowered.dropLast()) + guard let minutes = Double(value), minutes > 0 else { throw CliError.invalidDuration(raw) } + return minutes * 60 + } + if lowered.hasSuffix("h") { + let value = String(lowered.dropLast()) + guard let hours = Double(value), hours > 0 else { throw CliError.invalidDuration(raw) } + return hours * 3600 + } + throw CliError.invalidDuration(raw) +} + +func parseUInt32(_ value: String, argName: String) throws -> UInt32 { + guard let parsed = UInt32(value) else { + throw CliError.invalidValue(argName, value) + } + return parsed +} + +func parseInt64(_ value: String, argName: String) throws -> Int64 { + guard let parsed = Int64(value) else { + throw CliError.invalidValue(argName, value) + } + return parsed +} + +func parseDouble(_ value: String, argName: String) throws -> Double { + guard let parsed = Double(value) else { + throw CliError.invalidValue(argName, value) + } + return parsed +} + +func parseInt(_ value: String, argName: String) throws -> Int { + guard let parsed = Int(value) else { + throw CliError.invalidValue(argName, value) + } + return parsed +} + +func parseArgs() throws -> Command { + var argv = Array(CommandLine.arguments.dropFirst()) + guard let commandRaw = argv.first else { + throw CliError.invalidCommand("") + } + argv.removeFirst() + + var values: [String: String] = [:] + var flags = Set() + + var i = 0 + while i < argv.count { + let token = argv[i] + if token == "-q" || token == "--quiet" { + flags.insert("quiet") + i += 1 + continue + } + + guard token.hasPrefix("--") else { + throw CliError.invalidValue("argument", token) + } + + if let eq = token.firstIndex(of: "=") { + let key = String(token[token.startIndex.. 0 else { + throw CliError.invalidValue("--max-inflight-reducers", inflight) + } + bench.maxInflightReducers = UInt64(parsed) + } + if let path = values["--tps-write-path"] { + bench.tpsWritePath = path + } + return .bench(common, bench) + + default: + throw CliError.invalidCommand(commandRaw) + } +} + +@MainActor +func makeClient(common: CommonOptions, counter: CompletionCounter) throws -> (client: SpacetimeClient, delegate: BenchDelegate) { + guard let url = URL(string: common.server) else { + throw CliError.invalidURL(common.server) + } + + let client = SpacetimeClient( + serverUrl: url, + moduleName: common.module, + reconnectPolicy: nil, + compressionMode: .none + ) + client.setRawTransactionUpdateCountObserver { appliedCount in + counter.recordApplied(count: appliedCount) + } + let delegate = BenchDelegate(counter: counter) + client.delegate = delegate + return (client, delegate) +} + +func waitForConnected(_ client: SpacetimeClient, timeoutSeconds: Double) async throws { + let deadline = Date().addingTimeInterval(timeoutSeconds) + while Date() < deadline { + let state = await MainActor.run { client.connectionState } + if state == .connected { + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } + throw CliError.timeout("timed out waiting for connection") +} + +func waitForAcks(counter: CompletionCounter, targetTotalAcks: UInt64, timeoutSeconds: Double) async throws { + let deadline = Date().addingTimeInterval(timeoutSeconds) + while Date() < deadline { + let snapshot = counter.snapshot() + if snapshot.applied + snapshot.errors >= targetTotalAcks { + return + } + try await Task.sleep(nanoseconds: 1_000_000) + } + throw CliError.timeout("timed out waiting for reducer acknowledgements") +} + +func runSeed(common: CommonOptions, seed: SeedOptions) async throws { + let counter = CompletionCounter() + let setup = try await MainActor.run { try makeClient(common: common, counter: counter) } + let client = setup.client + _ = setup.delegate + + await MainActor.run { SpacetimeClient.clientCache = ClientCache() } + await MainActor.run { client.connect() } + try await waitForConnected(client, timeoutSeconds: 20) + + let encoder = BSATNEncoder() + let seedArgs = SeedArgs(n: common.accounts, balance: seed.initialBalance) + let payload = try encoder.encode(seedArgs) + + let before = counter.snapshot() + client.send("seed", payload) + try await waitForAcks( + counter: counter, + targetTotalAcks: (before.applied + before.errors) + 1, + timeoutSeconds: seed.waitTimeoutSeconds + ) + + await MainActor.run { client.disconnect() } + + if !common.quiet { + print("done seeding") + } +} + +func runBench(common: CommonOptions, bench: BenchOptions) async throws { + let durationSeconds = bench.durationSeconds + let transfers = makeTransfers(accounts: common.accounts, alpha: bench.alpha) + let transfersPerWorker = max(1, transfers.count / max(1, bench.connections)) + + if !common.quiet { + print("Benchmark parameters:") + print("alpha=\(bench.alpha), amount=\(common.amount), accounts=\(common.accounts)") + print("max inflight reducers = \(bench.maxInflightReducers)") + print() + print("benchmarking for \(durationSeconds)s...") + print("initializing \(bench.connections) connections") + } + + var clients: [SpacetimeClient] = [] + var delegates: [BenchDelegate] = [] + var counters: [CompletionCounter] = [] + clients.reserveCapacity(bench.connections) + delegates.reserveCapacity(bench.connections) + counters.reserveCapacity(bench.connections) + + await MainActor.run { SpacetimeClient.clientCache = ClientCache() } + + for _ in 0..= transfers.count { + transferIndex = 0 + } + + let args = TransferArgs(from: pair.0, to: pair.1, amount: amount) + let payload = try encoder.encode(args) + client.send("transfer", payload) + sent &+= 1 + } + + let ackTarget = (before.applied + before.errors) + sent + try await waitForAcks(counter: counter, targetTotalAcks: ackTarget, timeoutSeconds: 60) + let after = counter.snapshot() + localCompleted &+= (after.applied - before.applied) + } + + return localCompleted + } + } + + var total: UInt64 = 0 + for try await value in group { + total &+= value + } + return total + } + + let elapsed = Date().timeIntervalSince(start) + let tps = Double(completed) / elapsed + + for client in clients { + await MainActor.run { client.disconnect() } + } + + if !common.quiet { + print("ran for \(elapsed) seconds") + print("completed \(completed)") + print("throughput was \(tps) TPS") + } + + if let tpsWritePath = bench.tpsWritePath { + try String(tps).write(toFile: tpsWritePath, atomically: true, encoding: .utf8) + } +} + +@main +struct SpacetimeDBSwiftTransferSim { + static func main() async { + do { + switch try parseArgs() { + case .seed(let common, let seed): + try await runSeed(common: common, seed: seed) + case .bench(let common, let bench): + try await runBench(common: common, bench: bench) + } + } catch { + fputs("error: \(error)\n", stderr) + exit(1) + } + } +} diff --git a/templates/keynote-2/src/demo.ts b/templates/keynote-2/src/demo.ts index 099bd974b55..5ef9ebacf54 100644 --- a/templates/keynote-2/src/demo.ts +++ b/templates/keynote-2/src/demo.ts @@ -157,6 +157,11 @@ const serviceConfigs: Record = { healthCheck: async () => spacetimePing(), startCmd: 'spacetime start', }, + spacetimedb_swift: { + name: 'SpacetimeDB', + healthCheck: async () => spacetimePing(), + startCmd: 'spacetime start', + }, convex: { name: 'Convex', healthCheck: () => ping(3210), @@ -224,8 +229,10 @@ async function checkService(system: string): Promise { // ============================================================================ async function prepSystem(system: string): Promise { + const isSpacetimeBenchmarkClient = + system === 'spacetimedb' || system === 'spacetimedb_swift'; const connector = (CONNECTORS as any)[system]; - if (!connector) { + if (!isSpacetimeBenchmarkClient && !connector) { console.log(` ${system.padEnd(15)} ${c('yellow', '⚠ SKIPPED')}`); return; } @@ -233,7 +240,7 @@ async function prepSystem(system: string): Promise { const spinner = createSpinner(system.padEnd(15)); try { - if (system === 'spacetimedb') { + if (system === 'spacetimedb' || system === 'spacetimedb_swift') { const moduleName = process.env.STDB_MODULE || 'test-1'; const server = process.env.STDB_SERVER || 'local'; const server2 = process.env.STDB_SERVER || 'http://localhost:3000'; @@ -248,23 +255,43 @@ async function prepSystem(system: string): Promise { '--module-path', modulePath, ]); - await sh('cargo', [ - 'run', - //"--quiet", - "--manifest-path", - "spacetimedb-rust-client/Cargo.toml", - "--", - "seed", - //"--quiet", - '--server', - server2, - "--module", - moduleName, - "--accounts", - String(accounts), - "--initial-balance", - String(initialBalance), - ]); + if (system === 'spacetimedb') { + await sh('cargo', [ + 'run', + //"--quiet", + "--manifest-path", + "spacetimedb-rust-client/Cargo.toml", + "--", + "seed", + //"--quiet", + '--server', + server2, + "--module", + moduleName, + "--accounts", + String(accounts), + "--initial-balance", + String(initialBalance), + ]); + } else { + await sh('swift', [ + 'run', + '--package-path', + 'spacetimedb-swift-client', + '--configuration', + 'release', + 'SpacetimeDBSwiftTransferSim', + 'seed', + '--server', + server2, + '--module', + moduleName, + '--accounts', + String(accounts), + '--initial-balance', + String(initialBalance), + ]); + } console.log('[spacetimedb] seed complete.'); } else if (system === 'convex') { await initConvex(); @@ -354,9 +381,52 @@ async function runBenchmarkStdb(): Promise { }; } +async function runBenchmarkStdbSwift(): Promise { + const moduleName = process.env.STDB_MODULE || 'test-1'; + const server2 = process.env.STDB_SERVER || 'http://localhost:3000'; + + await sh('swift', [ + 'run', + '--package-path', + 'spacetimedb-swift-client', + '--configuration', + 'release', + 'SpacetimeDBSwiftTransferSim', + 'bench', + '--server', + server2, + '--module', + moduleName, + '--duration', + `${seconds}s`, + '--connections', + String(concurrency), + '--alpha', + String(alpha), + '--max-inflight-reducers', + '16384', + '--tps-write-path', + 'spacetimedb-swift-tps.tmp.log', + ]); + + const tpsStr = fs.readFileSync('spacetimedb-swift-tps.tmp.log', 'utf-8').trim(); + const tps = Number(tpsStr); + if (isNaN(tps)) { + console.warn(`[spacetimedb_swift] Failed to parse TPS from file: ${tpsStr}`); + return null; + } + + return { + system: 'spacetimedb_swift', + tps: Math.round(tps), + }; +} + async function runBenchmark(system: string): Promise { if (system === 'spacetimedb') { return await runBenchmarkStdb(); + } else if (system === 'spacetimedb_swift') { + return await runBenchmarkStdbSwift(); } else { return await runBenchmarkOther(system); } diff --git a/tools/check-swift-demo-bindings.sh b/tools/check-swift-demo-bindings.sh new file mode 100755 index 00000000000..b3159caa798 --- /dev/null +++ b/tools/check-swift-demo-bindings.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +if ! command -v cargo >/dev/null 2>&1; then + echo "error: cargo not found in PATH" >&2 + exit 1 +fi + +TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/spacetimedb-swift-bindings-check.XXXXXX")" +trap 'rm -rf "${TMP_ROOT}"' EXIT + +SIMPLE_TMP="${TMP_ROOT}/simple-generated" +NINJA_TMP="${TMP_ROOT}/ninja-generated" +mkdir -p "${SIMPLE_TMP}" "${NINJA_TMP}" + +echo "==> Regenerating simple-module Swift bindings" +cargo run -q -p spacetimedb-cli --manifest-path "${REPO_ROOT}/Cargo.toml" -- \ + generate \ + --lang swift \ + --out-dir "${SIMPLE_TMP}" \ + --module-path "${REPO_ROOT}/demo/simple-module/spacetimedb" \ + --no-config + +echo "==> Regenerating ninja-game Swift bindings" +cargo run -q -p spacetimedb-cli --manifest-path "${REPO_ROOT}/Cargo.toml" -- \ + generate \ + --lang swift \ + --out-dir "${NINJA_TMP}" \ + --module-path "${REPO_ROOT}/demo/ninja-game/spacetimedb" \ + --no-config + +SIMPLE_COMMITTED="${REPO_ROOT}/demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated" +NINJA_COMMITTED="${REPO_ROOT}/demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated" + +echo "==> Checking simple-module generated bindings drift" +if ! diff -ru "${SIMPLE_TMP}" "${SIMPLE_COMMITTED}"; then + echo "error: simple-module generated Swift bindings are out of date." >&2 + echo "run: cargo run -p spacetimedb-cli -- generate --lang swift --out-dir demo/simple-module/client-swift/Sources/SimpleModuleClient/Generated --module-path demo/simple-module/spacetimedb --no-config" >&2 + exit 1 +fi + +echo "==> Checking ninja-game generated bindings drift" +if ! diff -ru "${NINJA_TMP}" "${NINJA_COMMITTED}"; then + echo "error: ninja-game generated Swift bindings are out of date." >&2 + echo "run: cargo run -p spacetimedb-cli -- generate --lang swift --out-dir demo/ninja-game/client-swift/Sources/NinjaGameClient/Generated --module-path demo/ninja-game/spacetimedb --no-config" >&2 + exit 1 +fi + +echo "==> Generated Swift bindings are in sync." diff --git a/tools/swift-benchmark-baseline.sh b/tools/swift-benchmark-baseline.sh new file mode 100755 index 00000000000..6a9448fbd8a --- /dev/null +++ b/tools/swift-benchmark-baseline.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SDK_DIR="$ROOT_DIR/sdks/swift" +TARGET="SpacetimeDBBenchmarks" + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "Usage: $0 [benchmark-filter-regex]" >&2 + echo "Example: $0 macos14-arm64-swift6.2 '^RoundTrip (Reducer|Procedure)'" >&2 + exit 64 +fi + +BASELINE_NAME="$1" +FILTER_REGEX="${2:-}" +TIMESTAMP_UTC="$(date -u +"%Y%m%dT%H%M%SZ")" + +CAPTURE_DIR="$SDK_DIR/Benchmarks/Baselines/captures/$BASELINE_NAME" +RAW_RESULTS_FILE="$CAPTURE_DIR/${TIMESTAMP_UTC}.baseline-results.json" +SUMMARY_FILE="$CAPTURE_DIR/${TIMESTAMP_UTC}.summary.json" +METADATA_FILE="$CAPTURE_DIR/${TIMESTAMP_UTC}.metadata.txt" +LATEST_RAW_FILE="$CAPTURE_DIR/latest.baseline-results.json" +LATEST_SUMMARY_FILE="$CAPTURE_DIR/latest.summary.json" +LATEST_METADATA_FILE="$CAPTURE_DIR/latest.metadata.txt" + +mkdir -p "$CAPTURE_DIR" + +update_cmd=( + swift package --allow-writing-to-package-directory + benchmark baseline update "$BASELINE_NAME" + --target "$TARGET" + --benchmark-build-configuration release + --no-progress +) + +read_cmd=( + swift package + benchmark baseline read "$BASELINE_NAME" + --target "$TARGET" + --format jsonSmallerIsBetter + --path stdout + --no-progress +) + +if [[ -n "$FILTER_REGEX" ]]; then + update_cmd+=(--filter "$FILTER_REGEX") + read_cmd+=(--filter "$FILTER_REGEX") +fi + +( + cd "$SDK_DIR" + "${update_cmd[@]}" +) + +SOURCE_RESULTS_FILE="$SDK_DIR/.benchmarkBaselines/$TARGET/$BASELINE_NAME/results.json" +cp "$SOURCE_RESULTS_FILE" "$RAW_RESULTS_FILE" +cp "$RAW_RESULTS_FILE" "$LATEST_RAW_FILE" + +( + cd "$SDK_DIR" + "${read_cmd[@]}" +) > "$SUMMARY_FILE" +cp "$SUMMARY_FILE" "$LATEST_SUMMARY_FILE" + +{ + echo "baseline_name=$BASELINE_NAME" + echo "captured_at_utc=$TIMESTAMP_UTC" + if [[ -n "$FILTER_REGEX" ]]; then + echo "filter_regex=$FILTER_REGEX" + fi + echo "git_head=$(git -C "$ROOT_DIR" rev-parse HEAD)" + echo "swift_version=$(swift --version 2>&1 | tr '\n' ' ' | sed 's/[[:space:]]\\+/ /g')" + if command -v sw_vers >/dev/null 2>&1; then + echo "os_product_version=$(sw_vers -productVersion)" + echo "os_build_version=$(sw_vers -buildVersion)" + fi + echo "kernel=$(uname -sr)" + echo "arch=$(uname -m)" + printf "update_command=" + printf "%q " "${update_cmd[@]}" + echo + printf "read_command=" + printf "%q " "${read_cmd[@]}" + echo +} > "$METADATA_FILE" +cp "$METADATA_FILE" "$LATEST_METADATA_FILE" + +echo "Saved baseline capture:" +echo " raw: $RAW_RESULTS_FILE" +echo " summary: $SUMMARY_FILE" +echo " meta: $METADATA_FILE" +echo +echo "Compare against another baseline:" +echo " cd $SDK_DIR && swift package benchmark baseline compare $BASELINE_NAME --target $TARGET --no-progress" diff --git a/tools/swift-benchmark-smoke.sh b/tools/swift-benchmark-smoke.sh new file mode 100755 index 00000000000..78890fce1f4 --- /dev/null +++ b/tools/swift-benchmark-smoke.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SDK_PACKAGE_PATH="$ROOT_DIR/sdks/swift" + +swift package --package-path "$SDK_PACKAGE_PATH" benchmark list + +swift package \ + --package-path "$SDK_PACKAGE_PATH" \ + benchmark \ + --target SpacetimeDBBenchmarks \ + --filter "^(BSATN Encode Point3D|Message Encode Subscribe|RoundTrip Reducer.*)$" \ + --no-progress \ + --quiet + +swift package \ + --package-path "$SDK_PACKAGE_PATH" \ + benchmark \ + --target GeneratedBindingsBenchmarks \ + --filter "^(Generated Encode Row \\(Codable\\)|Generated Encode Row \\(BSATNSpecial\\)|Generated Cache Insert 1000 rows \\(BSATNSpecial\\))$" \ + --no-progress \ + --quiet diff --git a/tools/swift-docc-smoke.sh b/tools/swift-docc-smoke.sh new file mode 100755 index 00000000000..658517440fd --- /dev/null +++ b/tools/swift-docc-smoke.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SDK_DIR="$ROOT_DIR/sdks/swift" + +docc_cmd=( + xcodebuild docbuild + -scheme SpacetimeDB-Package + -destination 'generic/platform=macOS' + -derivedDataPath .build/docc + -quiet +) + +cd "$SDK_DIR" + +if "${docc_cmd[@]}"; then + echo "DocC smoke build succeeded without -skipPackagePluginValidation." +else + echo "DocC smoke build failed without plugin-validation skip; retrying with fallback." + "${docc_cmd[@]}" -skipPackagePluginValidation +fi diff --git a/tools/swift-package-mirror.sh b/tools/swift-package-mirror.sh new file mode 100755 index 00000000000..88372d34fb0 --- /dev/null +++ b/tools/swift-package-mirror.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE_DIR="$ROOT_DIR/sdks/swift/" + +usage() { + cat <<'EOF' +Usage: + tools/swift-package-mirror.sh sync --mirror [--allow-dirty] [--dry-run] + tools/swift-package-mirror.sh release --mirror --version [--allow-dirty] [--push] + +Commands: + sync Mirror sdks/swift into a standalone package-root repository. + release Sync + commit + tag (optionally push) in the mirror repository. + +Examples: + tools/swift-package-mirror.sh sync --mirror ../spacetimedb-swift + tools/swift-package-mirror.sh release --mirror ../spacetimedb-swift --version 0.1.0 --push +EOF +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +ensure_clean_repo() { + local repo="$1" + local allow_dirty="$2" + if [[ "$allow_dirty" == "1" ]]; then + return 0 + fi + if [[ -n "$(git -C "$repo" status --porcelain)" ]]; then + die "mirror repo has local changes; commit/stash first or use --allow-dirty" + fi +} + +sync_repo() { + local mirror_repo="$1" + local dry_run="$2" + + [[ -d "$mirror_repo/.git" ]] || die "mirror path is not a git repository: $mirror_repo" + + local rsync_flags=(-a --delete) + if [[ "$dry_run" == "1" ]]; then + rsync_flags+=(-n -v) + fi + + rsync \ + "${rsync_flags[@]}" \ + --exclude '.git' \ + --exclude '.build' \ + --exclude '.swiftpm' \ + --exclude '.DS_Store' \ + "$SOURCE_DIR" \ + "$mirror_repo/" +} + +create_release() { + local mirror_repo="$1" + local version="$2" + local push="$3" + + local tag="v$version" + if git -C "$mirror_repo" rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + die "tag already exists in mirror repo: $tag" + fi + + if [[ -n "$(git -C "$mirror_repo" status --porcelain)" ]]; then + git -C "$mirror_repo" add -A + git -C "$mirror_repo" commit -m "release: SpacetimeDB Swift SDK $tag" + else + echo "No synced file changes detected; tagging current HEAD." + fi + + git -C "$mirror_repo" tag -a "$tag" -m "SpacetimeDB Swift SDK $tag" + + if [[ "$push" == "1" ]]; then + git -C "$mirror_repo" push origin HEAD + git -C "$mirror_repo" push origin "$tag" + fi +} + +main() { + require_cmd git + require_cmd rsync + + if [[ $# -lt 1 ]]; then + usage + exit 64 + fi + + if [[ "$1" == "-h" || "$1" == "--help" || "$1" == "help" ]]; then + usage + exit 0 + fi + + local cmd="$1" + shift + + local mirror_repo="" + local version="" + local allow_dirty="0" + local dry_run="0" + local push="0" + + while [[ $# -gt 0 ]]; do + case "$1" in + --mirror) + [[ $# -ge 2 ]] || die "--mirror requires a value" + mirror_repo="$2" + shift 2 + ;; + --version) + [[ $# -ge 2 ]] || die "--version requires a value" + version="$2" + shift 2 + ;; + --allow-dirty) + allow_dirty="1" + shift + ;; + --dry-run) + dry_run="1" + shift + ;; + --push) + push="1" + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + die "unknown argument: $1" + ;; + esac + done + + [[ -n "$mirror_repo" ]] || die "--mirror is required" + + mirror_repo="$(cd "$mirror_repo" && pwd)" + + case "$cmd" in + sync) + ensure_clean_repo "$mirror_repo" "$allow_dirty" + sync_repo "$mirror_repo" "$dry_run" + echo "Mirror sync complete: $mirror_repo" + ;; + release) + [[ -n "$version" ]] || die "--version is required for release" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]] || die "--version must look like semver (example: 1.2.3)" + ensure_clean_repo "$mirror_repo" "$allow_dirty" + sync_repo "$mirror_repo" "0" + create_release "$mirror_repo" "$version" "$push" + echo "Release prepared in mirror repo: $mirror_repo" + echo "Version: v$version" + ;; + *) + die "unknown command: $cmd" + ;; + esac +} + +main "$@" diff --git a/tools/swift-procedure-e2e.md b/tools/swift-procedure-e2e.md new file mode 100644 index 00000000000..50f105e9bd8 --- /dev/null +++ b/tools/swift-procedure-e2e.md @@ -0,0 +1,32 @@ +# Swift Procedure E2E Script + +`tools/swift-procedure-e2e.sh` validates the generated Swift procedure callback path against a live local SpacetimeDB instance. + +It performs these steps: +- Publishes `modules/module-test` to a local database. +- Runs in-repo Swift code generation via `cargo run -p spacetimedb-cli -- generate --lang swift`. +- Compiles a temporary Swift runner against `sdks/swift` runtime sources plus the generated procedure wrapper. +- Invokes the generated procedure and waits for the callback result. + +## Dependencies + +- `spacetime` CLI in `PATH` +- `cargo` in `PATH` +- `swiftc` in `PATH` +- macOS (the script currently exits early on non-Darwin platforms) +- A running local SpacetimeDB server at `SERVER_URL` (default: `http://127.0.0.1:3000`) + +## Environment Overrides + +- `SERVER_URL`: SpacetimeDB server URL. +- `MODULE_PATH`: Module path relative to repo root (default: `modules/module-test`). +- `DB_NAME`: Database name used for publish (default: `swift-proc-e2e-`). +- `PROCEDURE_FILE`: Generated Swift file to compile (default: `SleepOneSecondProcedure.swift`). +- `PROCEDURE_TYPE`: Generated Swift procedure type to invoke (default: `SleepOneSecondProcedure`). + +## Example + +```bash +spacetime start & +tools/swift-procedure-e2e.sh +``` diff --git a/tools/swift-procedure-e2e.sh b/tools/swift-procedure-e2e.sh new file mode 100755 index 00000000000..381fd34574c --- /dev/null +++ b/tools/swift-procedure-e2e.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +SERVER_URL="${SERVER_URL:-http://127.0.0.1:3000}" +MODULE_PATH="${MODULE_PATH:-modules/module-test}" +PROCEDURE_FILE="${PROCEDURE_FILE:-SleepOneSecondProcedure.swift}" +PROCEDURE_TYPE="${PROCEDURE_TYPE:-SleepOneSecondProcedure}" +DB_NAME="${DB_NAME:-swift-proc-e2e-$(date +%s)}" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "error: this script currently requires macOS (Darwin)" >&2 + exit 1 +fi + +if ! command -v spacetime >/dev/null 2>&1; then + echo "error: spacetime CLI not found in PATH" >&2 + exit 1 +fi + +if ! command -v cargo >/dev/null 2>&1; then + echo "error: cargo not found in PATH" >&2 + exit 1 +fi + +if ! command -v swiftc >/dev/null 2>&1; then + echo "error: swiftc not found in PATH" >&2 + exit 1 +fi + +MODULE_ABS="${REPO_ROOT}/${MODULE_PATH}" +if [[ ! -d "${MODULE_ABS}" ]]; then + echo "error: module path not found: ${MODULE_ABS}" >&2 + exit 1 +fi + +OUT_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/spacetimedb-swift-proc-e2e.XXXXXX")" +GENERATED_DIR="${OUT_ROOT}/generated" +mkdir -p "${GENERATED_DIR}" + +echo "==> Publishing module '${MODULE_PATH}' to '${SERVER_URL}' as database '${DB_NAME}'" +spacetime publish \ + -s "${SERVER_URL}" \ + --anonymous \ + -y \ + -p "${MODULE_ABS}" \ + "${DB_NAME}" + +echo "==> Generating Swift bindings with in-repo CLI" +cargo run -q -p spacetimedb-cli --manifest-path "${REPO_ROOT}/Cargo.toml" -- \ + generate \ + --lang swift \ + --out-dir "${GENERATED_DIR}" \ + --module-path "${MODULE_ABS}" \ + --no-config + +GENERATED_PROCEDURE_FILE="${GENERATED_DIR}/${PROCEDURE_FILE}" +if [[ ! -f "${GENERATED_PROCEDURE_FILE}" ]]; then + echo "error: expected generated procedure file not found: ${GENERATED_PROCEDURE_FILE}" >&2 + echo "generated files:" >&2 + ls -1 "${GENERATED_DIR}" >&2 + exit 1 +fi + +RUNNER_FILE="${OUT_ROOT}/runner.swift" +RUNNER_BIN="${OUT_ROOT}/runner-bin" + +cat > "${RUNNER_FILE}" < Bool) async -> Bool { + let start = Date() + while Date().timeIntervalSince(start) < timeoutSeconds { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(50)) + } + return condition() +} + +@MainActor +func runE2E() async -> Int32 { + let delegate = E2EDelegate() + let client = SpacetimeClient(serverUrl: URL(string: "${SERVER_URL}")!, moduleName: "${DB_NAME}") + SpacetimeClient.shared = client + client.delegate = delegate + client.connect(token: nil) + + guard await waitUntil(10.0, condition: { delegate.connected }) else { + fputs("E2E FAIL: client did not connect within timeout\\n", stderr) + return 1 + } + + let start = Date() + var callbackResult: Result? + ${PROCEDURE_TYPE}.invoke { result in + callbackResult = result + } + + guard await waitUntil(20.0, condition: { callbackResult != nil }), let result = callbackResult else { + fputs("E2E FAIL: generated procedure callback was not received within timeout\\n", stderr) + return 1 + } + + switch result { + case .success: + let elapsed = Date().timeIntervalSince(start) + let elapsedString = String(format: "%.2f", elapsed) + print("E2E OK: generated ${PROCEDURE_TYPE} callback succeeded in \\(elapsedString)s") + client.disconnect() + return 0 + case .failure(let error): + fputs("E2E FAIL: generated procedure callback returned error: \\(error)\\n", stderr) + client.disconnect() + return 1 + } +} + +@main +struct Runner { + static func main() async { + exit(await runE2E()) + } +} +EOF + +echo "==> Compiling temporary Swift runner" +SDK_SOURCES=() +while IFS= read -r file; do + SDK_SOURCES+=("${file}") +done < <(find "${REPO_ROOT}/sdks/swift/Sources/SpacetimeDB" -name '*.swift' | LC_ALL=C sort) + +swiftc \ + "${SDK_SOURCES[@]}" \ + "${GENERATED_PROCEDURE_FILE}" \ + "${RUNNER_FILE}" \ + -o "${RUNNER_BIN}" + +echo "==> Running E2E procedure callback check" +"${RUNNER_BIN}" + +echo "==> Done" +echo "Artifacts kept in: ${OUT_ROOT}"