@crowdedkingdoms/ckx-kit 0.1.1-dev.1 → 0.2.0-test.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,7 @@ built by the platform from its source.
25
25
  ```bash
26
26
  npx @crowdedkingdoms/ckx-kit new mod my-mod
27
27
  cd my-mod
28
- cargo test # your logic, natively
28
+ cargo test # natively, against a fake of the platform
29
29
  npx @crowdedkingdoms/ckx-kit build --mod # the platform's build and checks
30
30
  ```
31
31
 
@@ -45,12 +45,36 @@ on the platform: Crowdy Studio, or `execModBuild` then `execModDeploy` (see
45
45
  `CKX_KIT_TARGET_DIR` shares one cargo target directory between crates, so their dependencies
46
46
  compile once.
47
47
 
48
+ ## Test
49
+
50
+ `cargo test` runs your crate natively against `ckx_sdk::testing` (from `ckx-sdk` 0.9.0), which
51
+ plays the platform. Every starter and template comes with tests written this way:
52
+
53
+ ```rust
54
+ use ckx_sdk::prelude::*;
55
+ use ckx_sdk::testing::{FakeGrid, TestHub};
56
+
57
+ let grid = FakeGrid::new(7, ChunkPos::new(0, 0, 0), ChunkPos::new(3, 1, 3), 42);
58
+ let mut m = TestHub::<GridMod>::mod_on("grid-mod", grid).unwrap();
59
+ let hello: String = m.call_typed(Caller::Player(9), "visit", &()).unwrap();
60
+ m.advance(60_000).unwrap(); // the test's clock: due timers run as the platform runs them
61
+ m.restart().unwrap(); // from the hub's snapshot, its timers and watches included
62
+ ```
63
+
64
+ `TestHub` and `TestSpoke` run a node as the platform does: calls from players, developers and
65
+ other instances; timers; topics, presence, sessions and a mod's world events; snapshots and
66
+ restarts. Each call is held to the platform's bounds (`ckx_sdk::limits`). A mod's `FakeGrid`
67
+ answers its node API operations with the node API's rules and refusals, and the platform's mod
68
+ rules apply: only players call it, its calls are refused, what it sends is dropped. For an app's
69
+ own hubs and spokes, the test scripts what other types and the node API answer.
70
+
48
71
  ## What it does not check
49
72
 
50
- Fuel, memory and deadlines are measured only where the module runs: on the platform. Your
51
- crate's native `cargo test` runs your logic, not the platform's limits. The platform also rebuilds
52
- from your source, so its module's digest differs from yours (the build paths embedded in it
53
- differ); the checks and the CLIENT capability hash do not.
73
+ Fuel, memory and deadlines are measured only where the module runs: on the platform. Native
74
+ tests do not see the app's manifest either, so they hold an app's own types to no call policy
75
+ and no node API scopes, and apply no node API rate limits. The platform also rebuilds from your
76
+ source, so its module's digest differs from yours (the build paths embedded in it differ); the
77
+ checks and the CLIENT capability hash do not.
54
78
 
55
79
  ## Versions
56
80
 
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ckx-sdk"
3
- version = "0.8.0-dev.1"
3
+ version = "0.9.0-test.1"
4
4
  edition = "2024"
5
5
  description = "Write ck-exec hubs, spokes and mods for Crowded Kingdoms in Rust, compiled to wasm32-unknown-unknown"
6
6
  license = "MIT OR Apache-2.0"
@@ -1,5 +1,6 @@
1
- //! Host imports (see `docs/GUEST-ABI.md`). Outside wasm32 they are stubs so node logic that
2
- //! makes no calls can be unit tested natively.
1
+ //! Host imports: what the sandbox gives a module. Outside wasm32 they reach the host a native
2
+ //! test puts on its thread ([`crate::testing`]); without one, logs go to stderr, the clock is
3
+ //! the system's and nothing else answers.
3
4
 
4
5
  #[cfg(target_arch = "wasm32")]
5
6
  mod host {
@@ -24,11 +25,6 @@ pub fn emit(inject: &[u8]) -> u32 {
24
25
  unsafe { host::emit(inject.as_ptr() as u32, inject.len() as u32) }
25
26
  }
26
27
 
27
- #[cfg(not(target_arch = "wasm32"))]
28
- pub fn emit(_inject: &[u8]) -> u32 {
29
- 0
30
- }
31
-
32
28
  #[cfg(target_arch = "wasm32")]
33
29
  pub fn wake_at(at_ms: u64) {
34
30
  unsafe { host::wake_at(at_ms) }
@@ -45,17 +41,6 @@ pub fn persist_now() {
45
41
  unsafe { host::persist_now() }
46
42
  }
47
43
 
48
- #[cfg(not(target_arch = "wasm32"))]
49
- pub fn wake_at(_at_ms: u64) {}
50
-
51
- #[cfg(not(target_arch = "wasm32"))]
52
- pub fn watch(_req: &[u8], _on: bool) -> u64 {
53
- 0
54
- }
55
-
56
- #[cfg(not(target_arch = "wasm32"))]
57
- pub fn persist_now() {}
58
-
59
44
  #[cfg(target_arch = "wasm32")]
60
45
  pub fn log(level: u32, msg: &str) {
61
46
  unsafe { host::log(level, msg.as_ptr() as u32, msg.len() as u32) }
@@ -86,30 +71,124 @@ pub fn node(op: &str, payload: &[u8], deadline_ms: u32) -> u64 {
86
71
  unsafe { host::node(op.as_ptr() as u32, op.len() as u32, payload.as_ptr() as u32, payload.len() as u32, deadline_ms) }
87
72
  }
88
73
 
74
+ /// The host a native build calls instead of the sandbox. Imports that return a result answer
75
+ /// `[status][payload]`, as the sandbox writes it into the module's memory.
76
+ #[cfg(not(target_arch = "wasm32"))]
77
+ pub(crate) mod native {
78
+ use std::cell::RefCell;
79
+
80
+ /// Each import answers as it does with no host unless the host says otherwise.
81
+ pub(crate) trait Host {
82
+ fn log(&mut self, level: u32, msg: &str) {
83
+ eprintln!("[ckx level {level}] {msg}");
84
+ }
85
+ fn now_ms(&mut self) -> u64 {
86
+ super::system_ms()
87
+ }
88
+ fn call(&mut self, _req: &[u8], _deadline_ms: u32) -> Vec<u8> {
89
+ Vec::new()
90
+ }
91
+ fn send(&mut self, _req: &[u8]) -> u32 {
92
+ 8
93
+ }
94
+ fn publish(&mut self, _topic: &str, _payload: &[u8]) {}
95
+ fn node(&mut self, _op: &str, _payload: &[u8], _deadline_ms: u32) -> Vec<u8> {
96
+ Vec::new()
97
+ }
98
+ fn wake_at(&mut self, _at_ms: u64) {}
99
+ fn watch(&mut self, _req: &[u8], _on: bool) -> Vec<u8> {
100
+ Vec::new()
101
+ }
102
+ fn persist_now(&mut self) {}
103
+ fn emit(&mut self, _inject: &[u8]) -> u32 {
104
+ 0
105
+ }
106
+ }
107
+
108
+ thread_local! {
109
+ static HOST: RefCell<Option<Box<dyn Host>>> = const { RefCell::new(None) };
110
+ /// The result the last import returned, which `rt::import_result` takes at once.
111
+ static RESULT: RefCell<Option<Vec<u8>>> = const { RefCell::new(None) };
112
+ }
113
+
114
+ /// Puts `host` on this thread (or takes it off) and returns the one it replaces.
115
+ pub(crate) fn install(host: Option<Box<dyn Host>>) -> Option<Box<dyn Host>> {
116
+ HOST.with(|h| std::mem::replace(&mut *h.borrow_mut(), host))
117
+ }
118
+
119
+ pub(crate) fn with<R>(f: impl FnOnce(&mut (dyn Host + 'static)) -> R) -> Option<R> {
120
+ HOST.with(|h| h.borrow_mut().as_deref_mut().map(f))
121
+ }
122
+
123
+ /// Keeps an import's result for `rt::import_result`: 0 is no reply, as from the sandbox.
124
+ pub(crate) fn result(buf: Option<Vec<u8>>) -> u64 {
125
+ match buf {
126
+ Some(b) if !b.is_empty() => {
127
+ RESULT.with(|r| *r.borrow_mut() = Some(b));
128
+ 1
129
+ }
130
+ _ => 0,
131
+ }
132
+ }
133
+
134
+ pub(crate) fn take_result() -> Option<Vec<u8>> {
135
+ RESULT.with(|r| r.borrow_mut().take())
136
+ }
137
+ }
138
+
139
+ #[cfg(not(target_arch = "wasm32"))]
140
+ pub fn emit(inject: &[u8]) -> u32 {
141
+ native::with(|h| h.emit(inject)).unwrap_or(0)
142
+ }
143
+
144
+ #[cfg(not(target_arch = "wasm32"))]
145
+ pub fn wake_at(at_ms: u64) {
146
+ native::with(|h| h.wake_at(at_ms));
147
+ }
148
+
149
+ #[cfg(not(target_arch = "wasm32"))]
150
+ pub fn watch(req: &[u8], on: bool) -> u64 {
151
+ native::result(native::with(|h| h.watch(req, on)))
152
+ }
153
+
154
+ #[cfg(not(target_arch = "wasm32"))]
155
+ pub fn persist_now() {
156
+ native::with(|h| h.persist_now());
157
+ }
158
+
159
+ #[cfg(not(target_arch = "wasm32"))]
160
+ fn system_ms() -> u64 {
161
+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
162
+ }
163
+
89
164
  #[cfg(not(target_arch = "wasm32"))]
90
165
  pub fn log(level: u32, msg: &str) {
91
- eprintln!("[ckx level {level}] {msg}");
166
+ if native::with(|h| h.log(level, msg)).is_none() {
167
+ eprintln!("[ckx level {level}] {msg}");
168
+ }
92
169
  }
93
170
 
94
171
  #[cfg(not(target_arch = "wasm32"))]
95
172
  pub fn now_ms() -> u64 {
96
- std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
173
+ native::with(|h| h.now_ms()).unwrap_or_else(system_ms)
97
174
  }
98
175
 
99
176
  #[cfg(not(target_arch = "wasm32"))]
100
- pub fn call(_req: &[u8], _deadline_ms: u32) -> u64 {
101
- 0
177
+ pub fn call(req: &[u8], deadline_ms: u32) -> u64 {
178
+ native::result(native::with(|h| h.call(req, deadline_ms)))
102
179
  }
103
180
 
104
181
  #[cfg(not(target_arch = "wasm32"))]
105
- pub fn send(_req: &[u8]) -> u32 {
106
- 8
182
+ pub fn send(req: &[u8]) -> u32 {
183
+ native::with(|h| h.send(req)).unwrap_or(8)
107
184
  }
108
185
 
109
186
  #[cfg(not(target_arch = "wasm32"))]
110
- pub fn publish(_topic: &str, _payload: &[u8]) {}
187
+ pub fn publish(topic: &str, payload: &[u8]) {
188
+ native::with(|h| h.publish(topic, payload));
189
+ }
111
190
 
112
191
  #[cfg(not(target_arch = "wasm32"))]
113
- pub fn node(_op: &str, _payload: &[u8], _deadline_ms: u32) -> u64 {
114
- 0
192
+ pub fn node(op: &str, payload: &[u8], deadline_ms: u32) -> u64 {
193
+ native::result(native::with(|h| h.node(op, payload, deadline_ms)))
115
194
  }
@@ -505,7 +505,7 @@ mod tests {
505
505
  format!("{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", s / 3600, s / 60 % 60, s % 60, ms % 1000)
506
506
  }
507
507
 
508
- // Generated from legacy's cron-parser 4.9.0 (cks-game-api's `computeNextRunAt`) with TZ=UTC:
508
+ // Generated from cron-parser 4.9.0, the legacy automations' scheduler, with TZ=UTC:
509
509
  // each expression, the instant it is asked from, and its next occurrences in order.
510
510
  const GOLDEN: &[(&str, &str, &[&str])] = &[
511
511
  ("* * * * *", "2026-09-26T12:00:00.000Z", &["2026-09-26T12:01:00.000Z", "2026-09-26T12:02:00.000Z", "2026-09-26T12:03:00.000Z"]),
@@ -40,14 +40,21 @@
40
40
  //! the app ([`Hub::on_presence`], root hub only) and players' connections subscribing to them
41
41
  //! ([`Hub::on_session`]). A timer can follow a cron schedule, the legacy automations' syntax:
42
42
  //! see [`cron`] and [`Ctx::timer_cron`].
43
+ //!
44
+ //! Nodes are unit tested natively with `cargo test`: `testing` runs one against a fake of the
45
+ //! platform (calls, timers, events, snapshots, the node API, a mod's grid). [`limits`] has the
46
+ //! platform's bounds.
43
47
 
44
48
  mod abi;
45
49
  pub mod cron;
46
50
  pub mod emit;
51
+ pub mod limits;
47
52
  pub mod model;
48
53
  pub mod node;
49
54
  #[doc(hidden)]
50
55
  pub mod rt;
56
+ #[cfg(not(target_arch = "wasm32"))]
57
+ pub mod testing;
51
58
 
52
59
  use serde::{Deserialize, Serialize, de::DeserializeOwned};
53
60
 
@@ -260,6 +267,25 @@ impl Status {
260
267
  }
261
268
  }
262
269
 
270
+ /// The wire status code: [`Status::from_u8`] reversed.
271
+ pub fn as_u8(self) -> u8 {
272
+ match self {
273
+ Status::Ok => 0,
274
+ Status::AppError => 1,
275
+ Status::Busy => 2,
276
+ Status::Moved => 3,
277
+ Status::NotFound => 4,
278
+ Status::DeadlineExceeded => 5,
279
+ Status::Denied => 6,
280
+ Status::RateLimited => 7,
281
+ Status::Unavailable => 8,
282
+ Status::Internal => 9,
283
+ Status::Trapped => 10,
284
+ Status::BadRequest => 11,
285
+ Status::Unknown(other) => other,
286
+ }
287
+ }
288
+
263
289
  pub fn is_retryable(self) -> bool {
264
290
  matches!(self, Status::Busy | Status::Moved | Status::Unavailable | Status::RateLimited)
265
291
  }
@@ -0,0 +1,52 @@
1
+ //! The platform's bounds on a node: what its sandbox and its checks hold every call, snapshot
2
+ //! and module to. [`crate::testing`] applies the ones a native test can see.
3
+
4
+ pub use crate::node::{MAX_CHUNK_STATE, MAX_VOXEL_STATE, MAX_VOXEL_WRITES};
5
+ pub use crate::rt::{MAX_TIMERS, MAX_WATCHES, MIN_TIMER_MS};
6
+
7
+ /// The largest payload a node may send in one message, and the largest reply it may return. A
8
+ /// larger reply is answered `Internal`.
9
+ pub const MAX_PAYLOAD: usize = 4 << 20;
10
+ /// What one call into a node may send on, in all: messages (calls, sends, publishes, emits,
11
+ /// node API requests, subscriptions) and bytes of their payloads. Past either, calls and node
12
+ /// API requests answer `Busy` and publishes are dropped.
13
+ pub const OUT_MSGS_PER_CALL: u32 = 16_384;
14
+ pub const OUT_BYTES_PER_CALL: u64 = 32 << 20;
15
+ /// Log lines one call may write (the rest are dropped), and the bytes of a line that are kept.
16
+ pub const LOGS_PER_CALL: u32 = 32;
17
+ pub const MAX_LOG_BYTES: usize = 4 << 10;
18
+ /// The largest realtime inject a node may emit: a spatial event's header and its payload.
19
+ pub const MAX_EMIT_BYTES: usize = 70 + crate::emit::MAX_PAYLOAD;
20
+ /// The largest snapshot a hub may persist. A larger one fails to save, and the last one stands.
21
+ pub const MAX_SNAPSHOT_BYTES: usize = 8 << 20;
22
+ /// The largest module an app's node type may deploy.
23
+ pub const ARTIFACT_MAX_BYTES: usize = 16 << 20;
24
+
25
+ /// What a manifest may ask for a node type, and what it gets when it asks for nothing.
26
+ pub mod manifest {
27
+ pub const MEMORY_DEFAULT_MB: u32 = 64;
28
+ pub const MEMORY_MAX_MB: u32 = 512;
29
+ pub const FUEL_DEFAULT: u64 = 50_000_000;
30
+ pub const FUEL_MAX: u64 = 1_000_000_000;
31
+ pub const DEADLINE_DEFAULT_MS: u32 = 2_000;
32
+ pub const DEADLINE_MAX_MS: u32 = 10_000;
33
+ }
34
+
35
+ /// What the platform gives every mod, in place of a manifest's limits.
36
+ pub mod mods {
37
+ /// A mod is the hub `("mod:<name>", "<grid id>")`.
38
+ pub const TYPE_PREFIX: &str = "mod:";
39
+ pub const MEMORY_MB: u32 = 32;
40
+ pub const FUEL_PER_CALL: u64 = 20_000_000;
41
+ pub const DEADLINE_MS: u32 = 2_000;
42
+ /// Handler calls per second, per mod instance.
43
+ pub const RATE_PER_SEC: u32 = 20;
44
+ pub const ARTIFACT_MAX_BYTES: usize = 4 << 20;
45
+ /// Mods one grid may run, and one owner may hold.
46
+ pub const PER_GRID_MAX: usize = 8;
47
+ pub const PER_OWNER_MAX: usize = 64;
48
+ /// World events in one `$world` call, at most.
49
+ pub const WORLD_BATCH_MAX: usize = 512;
50
+ /// The node API scopes every mod holds, each confined to its grid.
51
+ pub const SCOPES: [&str; 3] = ["grids.read", "world.read", "world.write"];
52
+ }
@@ -1,4 +1,4 @@
1
- //! Typed state for a hub (HS-27): what the legacy game models kept in the database, kept in the
1
+ //! Typed state for a hub: what the legacy game models kept in the database, kept in the
2
2
  //! hub instead. Containers of declared types hold typed properties with defaults, edges relate
3
3
  //! them, and each property's visibility decides what a player may read.
4
4
  //!
@@ -76,12 +76,12 @@ pub struct Voxel {
76
76
  pub updated_at: Option<String>,
77
77
  }
78
78
 
79
- /// One voxel to write: `voxel` is its position within `chunk`. The node API refuses a position
80
- /// outside the range below (since ck-api v2.29.0).
79
+ /// One voxel to write: `voxel` is its position within `chunk`.
81
80
  #[derive(Debug, Clone, PartialEq)]
82
81
  pub struct VoxelWrite {
83
82
  pub chunk: ChunkPos,
84
- /// `(x, y, z)` within the chunk: 0 to 15 on each axis.
83
+ /// `(x, y, z)` within the chunk. Positions are the app's to define; an app on the 16×16×16
84
+ /// dense grid keeps to 0 to 15 on each axis.
85
85
  pub voxel: (i16, i16, i16),
86
86
  /// What a type means is up to the app.
87
87
  pub voxel_type: i16,
@@ -193,21 +193,10 @@ fn bad_request(message: impl Into<String>) -> CallError {
193
193
 
194
194
  fn op<R: DeserializeOwned>(ctx: &Ctx, name: &str, body: Value) -> Result<R, CallError> {
195
195
  let body = serde_json::to_vec(&body).map_err(|e| bad_request(e.to_string()))?;
196
- let bytes = send(ctx, name, &body)?;
196
+ let bytes = ctx.node(name, &body)?;
197
197
  serde_json::from_slice(&bytes).map_err(|e| bad_reply(name, e))
198
198
  }
199
199
 
200
- #[cfg(not(test))]
201
- fn send(ctx: &Ctx, name: &str, body: &[u8]) -> Result<Vec<u8>, CallError> {
202
- ctx.node(name, body)
203
- }
204
-
205
- /// Unit tests stand in for the game API.
206
- #[cfg(test)]
207
- fn send(_ctx: &Ctx, name: &str, body: &[u8]) -> Result<Vec<u8>, CallError> {
208
- tests::NODE.with(|n| n.borrow_mut().answer(name, body))
209
- }
210
-
211
200
  fn b64(bytes: &[u8]) -> String {
212
201
  B64.encode(bytes)
213
202
  }
@@ -693,46 +682,54 @@ impl Access<'_> {
693
682
  mod tests {
694
683
  use std::cell::RefCell;
695
684
  use std::collections::VecDeque;
685
+ use std::rc::Rc;
696
686
 
697
687
  use super::*;
698
688
  use crate::Kind;
689
+ use crate::abi::native::{self, Host};
699
690
 
700
691
  /// The game API as the tests stand it in: every operation sent, with its body, and the
701
692
  /// replies to give, in order.
702
693
  #[derive(Default)]
703
- pub(super) struct FakeNode {
694
+ struct FakeNode {
704
695
  sent: Vec<(String, Value)>,
705
696
  replies: VecDeque<Value>,
706
697
  }
707
698
 
708
- impl FakeNode {
709
- pub(super) fn answer(&mut self, name: &str, body: &[u8]) -> Result<Vec<u8>, CallError> {
710
- self.sent.push((name.to_owned(), serde_json::from_slice(body).expect("a JSON body")));
711
- Ok(serde_json::to_vec(&self.replies.pop_front().expect("a reply for every operation")).unwrap())
699
+ struct Shared(Rc<RefCell<FakeNode>>);
700
+
701
+ impl Host for Shared {
702
+ fn node(&mut self, op: &str, payload: &[u8], _deadline_ms: u32) -> Vec<u8> {
703
+ let mut n = self.0.borrow_mut();
704
+ n.sent.push((op.to_owned(), serde_json::from_slice(payload).expect("a JSON body")));
705
+ let reply = n.replies.pop_front().expect("a reply for every operation");
706
+ [&[0][..], &serde_json::to_vec(&reply).unwrap()].concat()
712
707
  }
713
708
  }
714
709
 
715
- thread_local! {
716
- pub(super) static NODE: RefCell<FakeNode> = RefCell::default();
710
+ /// Runs `f` against the game API `node` stands in for.
711
+ fn with_node<T>(node: FakeNode, f: impl FnOnce(&Ctx) -> T) -> (T, FakeNode) {
712
+ let node = Rc::new(RefCell::new(node));
713
+ native::install(Some(Box::new(Shared(node.clone()))));
714
+ let ctx = Ctx { app: 7, instance: 1, epoch: 1, version: 1, kind: Kind::Hub, node_type: "t".into(), key: String::new() };
715
+ let out = f(&ctx);
716
+ native::install(None);
717
+ (out, Rc::try_unwrap(node).ok().expect("the host is gone").into_inner())
717
718
  }
718
719
 
719
720
  /// Runs `f`, the game API answering its one operation with `reply`: what `f` returned, and
720
721
  /// the operation and body it sent.
721
722
  fn exchange<T>(reply: Value, f: impl FnOnce(&Ctx) -> T) -> (T, String, Value) {
722
- NODE.with(|n| *n.borrow_mut() = FakeNode { sent: Vec::new(), replies: VecDeque::from([reply]) });
723
- let ctx = Ctx { app: 7, instance: 1, epoch: 1, version: 1, kind: Kind::Hub, node_type: "t".into(), key: String::new() };
724
- let out = f(&ctx);
725
- let (name, body) = NODE.with(|n| n.borrow_mut().sent.pop()).expect("one operation sent");
723
+ let (out, mut node) = with_node(FakeNode { sent: Vec::new(), replies: VecDeque::from([reply]) }, f);
724
+ let (name, body) = node.sent.pop().expect("one operation sent");
726
725
  (out, name, body)
727
726
  }
728
727
 
729
728
  /// `f` is refused before anything is sent.
730
729
  fn refused_here<T: std::fmt::Debug>(f: impl FnOnce(&Ctx) -> Result<T, CallError>) -> CallError {
731
- NODE.with(|n| *n.borrow_mut() = FakeNode::default());
732
- let ctx = Ctx { app: 7, instance: 1, epoch: 1, version: 1, kind: Kind::Hub, node_type: "t".into(), key: String::new() };
733
- let e = f(&ctx).expect_err("refused");
734
- assert!(NODE.with(|n| n.borrow().sent.is_empty()), "nothing sent");
735
- e
730
+ let (out, node) = with_node(FakeNode::default(), f);
731
+ assert!(node.sent.is_empty(), "nothing sent");
732
+ out.expect_err("refused")
736
733
  }
737
734
 
738
735
  fn pos(x: i64, y: i64, z: i64) -> Value {
@@ -1,6 +1,7 @@
1
1
  //! Glue between the exports the macros generate and the developer's node type.
2
2
 
3
3
  use std::alloc::{Layout, alloc as raw_alloc, dealloc};
4
+ #[cfg(target_arch = "wasm32")]
4
5
  use std::cell::UnsafeCell;
5
6
  use std::collections::{BTreeMap, BTreeSet};
6
7
 
@@ -25,10 +26,13 @@ const ENVELOPE: &[u8] = b"\xc1ckx2";
25
26
 
26
27
  /// A guest is single threaded; the host never calls in while a call is running, except
27
28
  /// `ckx_alloc`, which touches none of these.
29
+ #[cfg(target_arch = "wasm32")]
28
30
  struct GuestCell<T>(UnsafeCell<T>);
29
31
 
32
+ #[cfg(target_arch = "wasm32")]
30
33
  unsafe impl<T> Sync for GuestCell<T> {}
31
34
 
35
+ #[cfg(target_arch = "wasm32")]
32
36
  impl<T> GuestCell<T> {
33
37
  const fn new(v: T) -> Self {
34
38
  Self(UnsafeCell::new(v))
@@ -40,6 +44,42 @@ impl<T> GuestCell<T> {
40
44
  }
41
45
  }
42
46
 
47
+ /// Natively each thread is a guest of its own, so tests on parallel threads run a node each.
48
+ #[cfg(not(target_arch = "wasm32"))]
49
+ struct GuestCell<T: 'static>(fn() -> T);
50
+
51
+ #[cfg(not(target_arch = "wasm32"))]
52
+ thread_local! {
53
+ static GUEST: std::cell::RefCell<std::collections::HashMap<usize, Box<dyn std::any::Any>>> = Default::default();
54
+ }
55
+
56
+ #[cfg(not(target_arch = "wasm32"))]
57
+ impl<T: 'static> GuestCell<T> {
58
+ const fn new(init: fn() -> T) -> Self {
59
+ Self(init)
60
+ }
61
+
62
+ #[allow(clippy::mut_from_ref)]
63
+ fn get(&self) -> &mut T {
64
+ let key = self as *const Self as usize;
65
+ GUEST.with(|cells| {
66
+ let mut cells = cells.borrow_mut();
67
+ let cell = cells.entry(key).or_insert_with(|| Box::new((self.0)()));
68
+ let ptr: *mut T = cell.downcast_mut::<T>().expect("one type per cell");
69
+ unsafe { &mut *ptr }
70
+ })
71
+ }
72
+ }
73
+
74
+ macro_rules! guest_static {
75
+ ($name:ident: $t:ty = $init:expr) => {
76
+ #[cfg(target_arch = "wasm32")]
77
+ static $name: GuestCell<$t> = GuestCell::new($init);
78
+ #[cfg(not(target_arch = "wasm32"))]
79
+ static $name: GuestCell<$t> = GuestCell::new(|| $init);
80
+ };
81
+ }
82
+
43
83
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44
84
  struct Timer {
45
85
  /// Unix milliseconds.
@@ -83,7 +123,7 @@ impl Platform {
83
123
  }
84
124
  }
85
125
 
86
- static PLATFORM: GuestCell<Platform> = GuestCell::new(Platform { timers: BTreeMap::new(), watches: BTreeSet::new(), wake: 0 });
126
+ guest_static!(PLATFORM: Platform = Platform { timers: BTreeMap::new(), watches: BTreeSet::new(), wake: 0 });
87
127
 
88
128
  fn wrap(p: &Platform, state: &[u8]) -> Result<Vec<u8>> {
89
129
  let head = encode(p)?;
@@ -217,6 +257,17 @@ fn parse_topic(b: &[u8]) -> Result<TopicMsg<'_>> {
217
257
  Ok(TopicMsg { node_type, key, topic, payload: &b[at + 1 + tplen..] })
218
258
  }
219
259
 
260
+ #[cfg(not(target_arch = "wasm32"))]
261
+ impl<T: Hub> HubNode<T> {
262
+ pub(crate) fn hub(&self) -> &T {
263
+ &self.0
264
+ }
265
+
266
+ pub(crate) fn hub_mut(&mut self) -> &mut T {
267
+ &mut self.0
268
+ }
269
+ }
270
+
220
271
  impl<T: Hub> HubNode<T> {
221
272
  fn platform_call(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
222
273
  use ckx_platform::*;
@@ -253,8 +304,8 @@ impl<T: Hub> HubNode<T> {
253
304
  }
254
305
  }
255
306
 
256
- /// Reserved method names, as `ckx_proto::platform` defines them.
257
- mod ckx_platform {
307
+ /// The platform's reserved method names: its events.
308
+ pub(crate) mod ckx_platform {
258
309
  pub const TIMER: &str = "$timer";
259
310
  pub const TOPIC: &str = "$topic";
260
311
  pub const PRESENCE: &str = "$presence";
@@ -307,6 +358,17 @@ impl<T: Hub> Node for HubNode<T> {
307
358
 
308
359
  pub struct SpokeNode<T: Spoke>(T);
309
360
 
361
+ #[cfg(not(target_arch = "wasm32"))]
362
+ impl<T: Spoke> SpokeNode<T> {
363
+ pub(crate) fn spoke(&self) -> &T {
364
+ &self.0
365
+ }
366
+
367
+ pub(crate) fn spoke_mut(&mut self) -> &mut T {
368
+ &mut self.0
369
+ }
370
+ }
371
+
310
372
  impl<T: Spoke> Node for SpokeNode<T> {
311
373
  fn init(ctx: &Ctx, _seed: &[u8], _snapshot: Option<(&[u8], u64)>) -> Result<Self> {
312
374
  Ok(Self(T::start(ctx)?))
@@ -326,8 +388,8 @@ impl<T: Spoke> Node for SpokeNode<T> {
326
388
  fn evict(&mut self, _ctx: &Ctx) {}
327
389
  }
328
390
 
329
- static NODE: GuestCell<Option<(Ctx, Box<dyn DynNode>)>> = GuestCell::new(None);
330
- static OUT: GuestCell<Vec<u8>> = GuestCell::new(Vec::new());
391
+ guest_static!(NODE: Option<(Ctx, Box<dyn DynNode>)> = None);
392
+ guest_static!(OUT: Vec<u8> = Vec::new());
331
393
 
332
394
  fn layout(len: u32) -> Layout {
333
395
  Layout::from_size_align(len as usize, 1).expect("valid layout")
@@ -383,17 +445,26 @@ fn respond(result: Result<Vec<u8>>) -> u64 {
383
445
 
384
446
  /// Unpacks a result the host returned from an import.
385
447
  pub(crate) fn import_result(packed: u64) -> Result<Vec<u8>, CallError> {
386
- let (ptr, len) = ((packed >> 32) as u32, packed as u32);
387
- if len == 0 {
448
+ let Some(buf) = imported(packed) else {
388
449
  return Err(CallError { status: Status::Internal, message: "no reply from the host".into() });
389
- }
390
- let buf = take(ptr, len);
450
+ };
391
451
  match Status::from_u8(buf[0]) {
392
452
  Status::Ok => Ok(buf[1..].to_vec()),
393
453
  status => Err(CallError { status, message: String::from_utf8_lossy(&buf[1..]).into_owned() }),
394
454
  }
395
455
  }
396
456
 
457
+ #[cfg(target_arch = "wasm32")]
458
+ fn imported(packed: u64) -> Option<Vec<u8>> {
459
+ let (ptr, len) = ((packed >> 32) as u32, packed as u32);
460
+ (len != 0).then(|| take(ptr, len))
461
+ }
462
+
463
+ #[cfg(not(target_arch = "wasm32"))]
464
+ fn imported(packed: u64) -> Option<Vec<u8>> {
465
+ if packed == 0 { None } else { abi::native::take_result().filter(|b| !b.is_empty()) }
466
+ }
467
+
397
468
  fn parse_ctx(b: &[u8]) -> Result<Ctx> {
398
469
  let bad = || Error::new("malformed instance context");
399
470
  let u64_at = |at: usize| -> Result<u64> { Ok(u64::from_le_bytes(b.get(at..at + 8).ok_or_else(bad)?.try_into().unwrap())) };