@crowdedkingdoms/ckx-kit 0.0.0-stage → 0.1.1-dev.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.
Files changed (55) hide show
  1. package/LICENSE-APACHE +176 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +76 -2
  4. package/checks/compute-toolchain/host-catalog.json +68 -0
  5. package/crates/ckx-sdk/Cargo.toml +17 -0
  6. package/crates/ckx-sdk/LICENSE-APACHE +176 -0
  7. package/crates/ckx-sdk/LICENSE-MIT +21 -0
  8. package/crates/ckx-sdk/README.md +47 -0
  9. package/crates/ckx-sdk/src/abi.rs +115 -0
  10. package/crates/ckx-sdk/src/cron.rs +717 -0
  11. package/crates/ckx-sdk/src/emit.rs +140 -0
  12. package/crates/ckx-sdk/src/lib.rs +588 -0
  13. package/crates/ckx-sdk/src/model.rs +482 -0
  14. package/crates/ckx-sdk/src/node.rs +942 -0
  15. package/crates/ckx-sdk/src/rt.rs +519 -0
  16. package/crates/crowdy-client-sdk/Cargo.toml +15 -0
  17. package/crates/crowdy-client-sdk/LICENSE-APACHE +176 -0
  18. package/crates/crowdy-client-sdk/LICENSE-MIT +21 -0
  19. package/crates/crowdy-client-sdk/README.md +25 -0
  20. package/crates/crowdy-client-sdk/src/lib.rs +698 -0
  21. package/dist/checks/src/build/client-capabilities.js +123 -0
  22. package/dist/checks/src/build/exec-source.js +343 -0
  23. package/dist/checks/src/build/rust-source-guard.js +165 -0
  24. package/dist/checks/src/build/wasm-inspect.js +249 -0
  25. package/dist/cli/src/build.js +99 -0
  26. package/dist/cli/src/crate.js +64 -0
  27. package/dist/cli/src/doctor.js +51 -0
  28. package/dist/cli/src/kit.js +66 -0
  29. package/dist/cli/src/main.js +109 -0
  30. package/dist/cli/src/new.js +74 -0
  31. package/dist/cli/src/tools.js +104 -0
  32. package/kit.pin +9 -0
  33. package/package.json +81 -4
  34. package/rust-toolchain.toml +6 -0
  35. package/starters/ckx.json +9 -0
  36. package/starters/grid-mod/Cargo.toml +13 -0
  37. package/starters/grid-mod/src/lib.rs +82 -0
  38. package/starters/matchmaker/Cargo.toml +13 -0
  39. package/starters/matchmaker/src/lib.rs +362 -0
  40. package/starters/npc-mobs/Cargo.toml +14 -0
  41. package/starters/npc-mobs/src/lib.rs +469 -0
  42. package/starters/session/Cargo.toml +13 -0
  43. package/starters/session/src/lib.rs +395 -0
  44. package/starters/world-tick/Cargo.toml +14 -0
  45. package/starters/world-tick/src/lib.rs +346 -0
  46. package/templates/client/Cargo.toml +16 -0
  47. package/templates/client/src/lib.rs +31 -0
  48. package/templates/hub/Cargo.toml +11 -0
  49. package/templates/hub/src/lib.rs +73 -0
  50. package/templates/spoke/Cargo.toml +11 -0
  51. package/templates/spoke/src/lib.rs +53 -0
  52. package/tools/instrument/Cargo.lock +140 -0
  53. package/tools/instrument/Cargo.toml +11 -0
  54. package/tools/instrument/src/main.rs +165 -0
  55. package/vendor.lock +167 -0
@@ -0,0 +1,698 @@
1
+ //! `crowdy-client-sdk`: the guest SDK for the CLIENT half of a ck-exec mod.
2
+ //!
3
+ //! A CLIENT half runs in the browser of each player who visits the mod's grid and consents to
4
+ //! it: a HUD, an overlay, input handling. The game API builds it for `wasm32-unknown-unknown`
5
+ //! (`execModClientBuild`), attaches it to the mod (`execModClientDeploy`) and serves it to
6
+ //! visitors (`execModClientArtifact`); CrowdyJS's player broker runs it in a Web Worker and
7
+ //! answers its host calls through the grid-confined allowlist.
8
+ //!
9
+ //! ABI v0 is the one the browser glue implements. The module imports `ck::{log, now_ms,
10
+ //! state_get, state_set, host_call}`, and `wasi_snapshot_preview1::random_get` only when it
11
+ //! calls [`random_bytes`]. It exports `ck_alloc`, `ck_free` and, through [`register_module!`],
12
+ //! `init`, `tick(dt_ms)`, `handle_invoke(ptr, len) -> u64` and optionally `on_event(ptr, len)`.
13
+ //! Buffers cross as `(ptr, len)` in guest memory; `handle_invoke` and `host_call` return a
14
+ //! packed `ptr << 32 | len` whose buffer the receiver releases with `ck_free`.
15
+ //!
16
+ //! [`state_get`] / [`state_set`] hold one blob for the life of the worker; nothing persists it.
17
+ //! Every dispatch runs on a fuel budget the platform injects at build time, so a runaway
18
+ //! `tick` traps instead of hanging the page.
19
+
20
+ pub const ABI_VERSION: u32 = 0;
21
+
22
+ /// Host imports (module `ck`).
23
+ #[cfg(target_arch = "wasm32")]
24
+ pub mod host {
25
+ #[link(wasm_import_module = "ck")]
26
+ extern "C" {
27
+ /// level: 0 debug, 1 info, 2 warn, 3 error.
28
+ pub fn log(level: u32, ptr: *const u8, len: u32);
29
+ /// Milliseconds since the Unix epoch, from the browser's clock.
30
+ pub fn now_ms() -> u64;
31
+ /// Copies up to `cap` bytes of the state blob into `dest` and returns the blob's full
32
+ /// length (call with `cap = 0` to size it).
33
+ pub fn state_get(dest: *mut u8, cap: u32) -> u32;
34
+ /// Replaces the state blob. Returns 0.
35
+ pub fn state_set(ptr: *const u8, len: u32) -> u32;
36
+ /// The host-call gateway: a JSON request in guest memory, answered synchronously with
37
+ /// a packed (resp_ptr << 32 | resp_len) JSON response that the guest frees.
38
+ pub fn host_call(ptr: *const u8, len: u32) -> u64;
39
+ }
40
+ }
41
+
42
+ /// Native stand-in for the host, so a crate built on the SDK can `cargo test` without a
43
+ /// browser: it delegates to the thread's [`testhost`]. Absent from wasm builds.
44
+ #[cfg(not(target_arch = "wasm32"))]
45
+ pub mod host {
46
+ use crate::testhost;
47
+
48
+ pub unsafe fn log(level: u32, ptr: *const u8, len: u32) {
49
+ let msg = String::from_utf8_lossy(core::slice::from_raw_parts(ptr, len as usize))
50
+ .into_owned();
51
+ testhost::with(|m| m.logs.push((level, msg)));
52
+ }
53
+
54
+ pub unsafe fn now_ms() -> u64 {
55
+ testhost::with(|m| m.now_ms)
56
+ }
57
+
58
+ pub unsafe fn state_get(dest: *mut u8, cap: u32) -> u32 {
59
+ testhost::with(|m| {
60
+ let len = m.state.len() as u32;
61
+ if !dest.is_null() && cap > 0 {
62
+ let n = len.min(cap) as usize;
63
+ core::ptr::copy_nonoverlapping(m.state.as_ptr(), dest, n);
64
+ }
65
+ len
66
+ })
67
+ }
68
+
69
+ pub unsafe fn state_set(ptr: *const u8, len: u32) -> u32 {
70
+ let bytes = core::slice::from_raw_parts(ptr, len as usize).to_vec();
71
+ testhost::with(|m| m.state = bytes);
72
+ 0
73
+ }
74
+ }
75
+
76
+ /// A scriptable fake host for native `cargo test`: install a handler for host calls, set the
77
+ /// clock, and read the logs and state. Thread-local, so parallel tests are isolated. Not
78
+ /// compiled for wasm.
79
+ #[cfg(not(target_arch = "wasm32"))]
80
+ pub mod testhost {
81
+ use std::cell::RefCell;
82
+
83
+ type HostCallFn = Box<
84
+ dyn FnMut(&str, &serde_json::Value) -> Result<serde_json::Value, (String, Option<String>)>,
85
+ >;
86
+
87
+ pub struct MockHost {
88
+ pub now_ms: u64,
89
+ pub state: Vec<u8>,
90
+ pub logs: Vec<(u32, String)>,
91
+ pub rng_seed: u64,
92
+ pub on_host_call: Option<HostCallFn>,
93
+ }
94
+
95
+ impl Default for MockHost {
96
+ fn default() -> Self {
97
+ Self { now_ms: 0, state: Vec::new(), logs: Vec::new(), rng_seed: 1, on_host_call: None }
98
+ }
99
+ }
100
+
101
+ thread_local! {
102
+ static MOCK: RefCell<MockHost> = RefCell::new(MockHost::default());
103
+ }
104
+
105
+ /// Installs a fresh mock (call at the start of each test).
106
+ pub fn install(mock: MockHost) {
107
+ MOCK.with(|cell| *cell.borrow_mut() = mock);
108
+ }
109
+
110
+ /// Resets to the defaults.
111
+ pub fn reset() {
112
+ install(MockHost::default());
113
+ }
114
+
115
+ /// The current thread's mock.
116
+ pub fn with<R>(f: impl FnOnce(&mut MockHost) -> R) -> R {
117
+ MOCK.with(|cell| f(&mut cell.borrow_mut()))
118
+ }
119
+
120
+ pub(crate) fn dispatch(request: &[u8]) -> Vec<u8> {
121
+ #[derive(serde::Deserialize)]
122
+ struct Req {
123
+ #[serde(rename = "fn")]
124
+ fn_name: String,
125
+ #[serde(default)]
126
+ args: serde_json::Value,
127
+ }
128
+ let outcome = match serde_json::from_slice::<Req>(request) {
129
+ Ok(req) => {
130
+ // Taken out while it runs, so the handler may use testhost::with itself.
131
+ let mut handler = with(|m| m.on_host_call.take());
132
+ let result = match handler.as_mut() {
133
+ Some(h) => h(&req.fn_name, &req.args),
134
+ None => Err(("no_mock_handler".into(), Some(format!("fn {}", req.fn_name)))),
135
+ };
136
+ if let Some(h) = handler {
137
+ with(|m| {
138
+ if m.on_host_call.is_none() {
139
+ m.on_host_call = Some(h);
140
+ }
141
+ });
142
+ }
143
+ result
144
+ }
145
+ Err(e) => Err(("bad_request".into(), Some(e.to_string()))),
146
+ };
147
+ let body = match outcome {
148
+ Ok(data) => serde_json::json!({ "ok": true, "data": data }),
149
+ Err((kind, message)) => {
150
+ serde_json::json!({ "ok": false, "error": { "kind": kind, "message": message } })
151
+ }
152
+ };
153
+ serde_json::to_vec(&body).expect("serialize mock response")
154
+ }
155
+
156
+ /// Deterministic bytes for the native `random_bytes` (xorshift64*).
157
+ pub(crate) fn random(len: usize) -> Vec<u8> {
158
+ with(|m| {
159
+ let mut out = Vec::with_capacity(len);
160
+ let mut s = m.rng_seed.max(1);
161
+ while out.len() < len {
162
+ s ^= s >> 12;
163
+ s ^= s << 25;
164
+ s ^= s >> 27;
165
+ let v = s.wrapping_mul(0x2545F4914F6CDD1D);
166
+ out.extend_from_slice(&v.to_le_bytes());
167
+ }
168
+ m.rng_seed = s;
169
+ out.truncate(len);
170
+ out
171
+ })
172
+ }
173
+ }
174
+
175
+ pub fn log(level: u32, msg: &str) {
176
+ unsafe { host::log(level, msg.as_ptr(), msg.len() as u32) }
177
+ }
178
+
179
+ pub fn now_ms() -> u64 {
180
+ unsafe { host::now_ms() }
181
+ }
182
+
183
+ /// The state blob (empty when unset).
184
+ pub fn state_get() -> Vec<u8> {
185
+ let len = unsafe { host::state_get(core::ptr::null_mut(), 0) };
186
+ if len == 0 {
187
+ return Vec::new();
188
+ }
189
+ let mut buf = vec![0u8; len as usize];
190
+ let written = unsafe { host::state_get(buf.as_mut_ptr(), len) };
191
+ buf.truncate(written.min(len) as usize);
192
+ buf
193
+ }
194
+
195
+ /// Replaces the state blob.
196
+ pub fn state_set(bytes: &[u8]) -> bool {
197
+ unsafe { host::state_set(bytes.as_ptr(), bytes.len() as u32) == 0 }
198
+ }
199
+
200
+ /// Random bytes from the browser's `crypto.getRandomValues`.
201
+ #[cfg(target_arch = "wasm32")]
202
+ pub fn random_bytes(len: usize) -> Vec<u8> {
203
+ let mut buf = vec![0u8; len];
204
+ unsafe {
205
+ wasi_random_get(buf.as_mut_ptr(), len as u32);
206
+ }
207
+ buf
208
+ }
209
+
210
+ /// Native stand-in: deterministic bytes from the test host's seed.
211
+ #[cfg(not(target_arch = "wasm32"))]
212
+ pub fn random_bytes(len: usize) -> Vec<u8> {
213
+ testhost::random(len)
214
+ }
215
+
216
+ #[cfg(target_arch = "wasm32")]
217
+ #[link(wasm_import_module = "wasi_snapshot_preview1")]
218
+ extern "C" {
219
+ #[link_name = "random_get"]
220
+ fn wasi_random_get(ptr: *mut u8, len: u32) -> u16;
221
+ }
222
+
223
+ /// Allocates a buffer the host writes into before it calls an export.
224
+ #[no_mangle]
225
+ pub extern "C" fn ck_alloc(len: u32) -> *mut u8 {
226
+ let mut buf = Vec::<u8>::with_capacity(len as usize);
227
+ let ptr = buf.as_mut_ptr();
228
+ core::mem::forget(buf);
229
+ ptr
230
+ }
231
+
232
+ /// Releases a buffer handed across the boundary.
233
+ ///
234
+ /// # Safety
235
+ /// `ptr`/`len` must come from `ck_alloc` or a packed return value.
236
+ #[no_mangle]
237
+ pub unsafe extern "C" fn ck_free(ptr: *mut u8, len: u32) {
238
+ drop(Vec::from_raw_parts(ptr, len as usize, len as usize));
239
+ }
240
+
241
+ /// Leaks `bytes` and packs its (ptr, len) into the u64 return convention.
242
+ pub fn pack_return(bytes: Vec<u8>) -> u64 {
243
+ let len = bytes.len() as u64;
244
+ let mut boxed = bytes.into_boxed_slice();
245
+ let ptr = boxed.as_mut_ptr() as u64;
246
+ core::mem::forget(boxed);
247
+ (ptr << 32) | len
248
+ }
249
+
250
+ /// A host-provided buffer.
251
+ ///
252
+ /// # Safety
253
+ /// `ptr`/`len` must describe a live buffer in guest memory.
254
+ pub unsafe fn input_slice<'a>(ptr: *const u8, len: u32) -> &'a [u8] {
255
+ core::slice::from_raw_parts(ptr, len as usize)
256
+ }
257
+
258
+ /// A host call the broker or the page refused, or that failed.
259
+ ///
260
+ /// The browser answers a refusal (outside the allowlist, outside the grid, over a rate cap,
261
+ /// not offered by this game) with `kind` `denied` and a message saying why.
262
+ #[derive(Debug, Clone, serde::Deserialize)]
263
+ pub struct HostError {
264
+ pub kind: String,
265
+ #[serde(default)]
266
+ pub message: Option<String>,
267
+ }
268
+
269
+ impl core::fmt::Display for HostError {
270
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
271
+ match &self.message {
272
+ Some(m) => write!(f, "{}: {}", self.kind, m),
273
+ None => write!(f, "{}", self.kind),
274
+ }
275
+ }
276
+ }
277
+
278
+ /// On wasm the response crosses as a packed (ptr << 32 | len) buffer in 32-bit memory;
279
+ /// native tests route straight to the test host.
280
+ #[cfg(target_arch = "wasm32")]
281
+ fn host_call_raw(req: &[u8]) -> Vec<u8> {
282
+ let packed = unsafe { host::host_call(req.as_ptr(), req.len() as u32) };
283
+ let ptr = (packed >> 32) as u32 as *mut u8;
284
+ let len = (packed & 0xffff_ffff) as u32;
285
+ let bytes = unsafe { core::slice::from_raw_parts(ptr, len as usize).to_vec() };
286
+ unsafe { ck_free(ptr, len) };
287
+ bytes
288
+ }
289
+
290
+ #[cfg(not(target_arch = "wasm32"))]
291
+ fn host_call_raw(req: &[u8]) -> Vec<u8> {
292
+ testhost::dispatch(req)
293
+ }
294
+
295
+ /// Calls a host function with JSON args and returns its `data`.
296
+ pub fn host_call(fn_name: &str, args: serde_json::Value) -> Result<serde_json::Value, HostError> {
297
+ #[derive(serde::Serialize)]
298
+ struct Request<'a> {
299
+ #[serde(rename = "fn")]
300
+ fn_name: &'a str,
301
+ args: serde_json::Value,
302
+ }
303
+ #[derive(serde::Deserialize)]
304
+ struct Response {
305
+ ok: bool,
306
+ #[serde(default)]
307
+ data: serde_json::Value,
308
+ #[serde(default)]
309
+ error: Option<HostError>,
310
+ }
311
+
312
+ let req = serde_json::to_vec(&Request { fn_name, args }).expect("serialize");
313
+ let bytes = host_call_raw(&req);
314
+ let resp: Response = serde_json::from_slice(&bytes)
315
+ .map_err(|e| HostError { kind: "bad_response".into(), message: Some(e.to_string()) })?;
316
+ if resp.ok {
317
+ Ok(resp.data)
318
+ } else {
319
+ Err(resp.error.unwrap_or(HostError { kind: "unknown".into(), message: None }))
320
+ }
321
+ }
322
+
323
+ /// The client host calls. The broker confines each to the mod's grid (chunk coordinates
324
+ /// inside the grid's box, `gridId` the mod's own) and the page answers it as the visiting
325
+ /// player, with that player's permissions; a game may leave some unanswered (`denied`).
326
+ /// Ids are strings where they may exceed 2^53.
327
+ pub mod api {
328
+ use super::{host_call, HostError};
329
+ use serde_json::{json, Value};
330
+
331
+ // -- State ---------------------------------------------------------------------------
332
+
333
+ /// The visiting player's app state.
334
+ pub fn user_state_get(user_id: &str) -> Result<Value, HostError> {
335
+ host_call("user_state_get", json!({ "userId": user_id }))
336
+ }
337
+
338
+ /// Replaces the visiting player's app state; `state_base64` is the new bytes.
339
+ pub fn user_state_set(user_id: &str, state_base64: &str) -> Result<Value, HostError> {
340
+ host_call("user_state_set", json!({ "userId": user_id, "stateBase64": state_base64 }))
341
+ }
342
+
343
+ /// An avatar's app state, for an avatar whose actor is inside the grid.
344
+ pub fn avatar_state_get(avatar_id: &str) -> Result<Value, HostError> {
345
+ host_call("avatar_state_get", json!({ "avatarId": avatar_id }))
346
+ }
347
+
348
+ // -- World -----------------------------------------------------------------------------
349
+
350
+ /// One chunk inside the grid: `{ voxelsBase64 }`.
351
+ pub fn chunk_get(x: i64, y: i64, z: i64) -> Result<Value, HostError> {
352
+ host_call("chunk_get", json!({ "x": x, "y": y, "z": z }))
353
+ }
354
+
355
+ /// The voxels recorded in one chunk inside the grid.
356
+ pub fn voxels_list(x: i64, y: i64, z: i64) -> Result<Value, HostError> {
357
+ host_call("voxels_list", json!({ "x": x, "y": y, "z": z }))
358
+ }
359
+
360
+ /// The actors in one chunk inside the grid, as the page sees them.
361
+ pub fn actors_list(x: i64, y: i64, z: i64) -> Result<Value, HostError> {
362
+ host_call("actors_list", json!({ "x": x, "y": y, "z": z }))
363
+ }
364
+
365
+ /// The actors in a box of chunks around (x, y, z): `radius_xz` at most 3 and `radius_y`
366
+ /// at most 1, clipped to the grid.
367
+ pub fn actors_list_radius(
368
+ x: i64,
369
+ y: i64,
370
+ z: i64,
371
+ radius_xz: u8,
372
+ radius_y: u8,
373
+ ) -> Result<Value, HostError> {
374
+ host_call(
375
+ "actors_list_radius",
376
+ json!({ "x": x, "y": y, "z": z, "radiusXz": radius_xz, "radiusY": radius_y }),
377
+ )
378
+ }
379
+
380
+ /// Writes one voxel inside the grid, as the visiting player (their `update_voxel_data`).
381
+ pub fn voxel_set(
382
+ chunk: (i64, i64, i64),
383
+ voxel: (u8, u8, u8),
384
+ voxel_type: i32,
385
+ state_base64: Option<&str>,
386
+ ) -> Result<Value, HostError> {
387
+ host_call(
388
+ "voxel_set",
389
+ json!({
390
+ "chunkX": chunk.0, "chunkY": chunk.1, "chunkZ": chunk.2,
391
+ "voxelX": voxel.0, "voxelY": voxel.1, "voxelZ": voxel.2,
392
+ "voxelType": voxel_type, "stateBase64": state_base64,
393
+ }),
394
+ )
395
+ }
396
+
397
+ // -- Grid ------------------------------------------------------------------------------
398
+
399
+ /// Whether the visiting player holds `permission_key` on this grid.
400
+ pub fn grid_permission_check(
401
+ user_id: &str,
402
+ grid_id: &str,
403
+ permission_key: &str,
404
+ ) -> Result<bool, HostError> {
405
+ let data = host_call(
406
+ "grid_permission_check",
407
+ json!({ "userId": user_id, "gridId": grid_id, "permissionKey": permission_key }),
408
+ )?;
409
+ Ok(data.as_bool().unwrap_or(false))
410
+ }
411
+
412
+ /// The mod's own grid, answered by the broker: `{ low: {x,y,z}, high: {x,y,z} }` in chunk
413
+ /// coordinates, as decimal strings.
414
+ pub fn grid_info() -> Result<Value, HostError> {
415
+ host_call("grid_info", json!({}))
416
+ }
417
+
418
+ // -- Presentation and input ------------------------------------------------------------
419
+
420
+ /// Hands `payload` to the game's HUD channel. The mod never touches the DOM; a game that
421
+ /// offers no HUD answers `{ delivered: false }`.
422
+ pub fn hud_set(payload: Value) -> Result<Value, HostError> {
423
+ host_call("hud_set", json!({ "payload": payload }))
424
+ }
425
+
426
+ /// Hands `payload` to the game's overlay channel, as [`hud_set`] does.
427
+ pub fn overlay_draw(payload: Value) -> Result<Value, HostError> {
428
+ host_call("overlay_draw", json!({ "payload": payload }))
429
+ }
430
+
431
+ /// Drains the mouse clicks queued since the last call: `{ nowMs, buttons, holdingMs,
432
+ /// clicks }`. `buttons` is `MouseEvent.buttons` (1 = left held); `holdingMs` maps a button
433
+ /// index to how long it has been held (`"0"` = left); each click is `{ t: "down"|"up",
434
+ /// button, atMs, heldMs?, nx, ny }` in canvas coordinates from -1 to 1 (+ny up). Call it
435
+ /// every tick.
436
+ pub fn pointer_clicks() -> Result<Value, HostError> {
437
+ host_call("pointer_clicks", json!({}))
438
+ }
439
+
440
+ /// The visiting player's movement axes, drained by the page: `{ x, y }`.
441
+ pub fn input_axes() -> Result<Value, HostError> {
442
+ host_call("input_axes", json!({}))
443
+ }
444
+
445
+ /// Pointer-look delta since the last poll: `{ dx, dy }`.
446
+ pub fn input_look() -> Result<Value, HostError> {
447
+ host_call("input_look", json!({}))
448
+ }
449
+
450
+ /// Whether the visiting player is holding `code` (a `KeyboardEvent.code`).
451
+ pub fn input_key(code: &str) -> Result<Value, HostError> {
452
+ host_call("input_key", json!({ "code": code }))
453
+ }
454
+
455
+ /// The page's wall clock, milliseconds.
456
+ pub fn clock() -> Result<Value, HostError> {
457
+ host_call("clock", json!({}))
458
+ }
459
+
460
+ /// The visiting player's body. The page answers from the mod's hold, or the walker.
461
+ pub fn pose_get() -> Result<Value, HostError> {
462
+ host_call("pose_get", json!({}))
463
+ }
464
+
465
+ /// Moves the visiting player's body. A position outside the grid is refused.
466
+ pub fn pose_set(pose: Value) -> Result<Value, HostError> {
467
+ host_call("pose_set", pose)
468
+ }
469
+
470
+ /// Hands the visiting player's body back to the walker.
471
+ pub fn pose_release() -> Result<Value, HostError> {
472
+ host_call("pose_release", json!({}))
473
+ }
474
+
475
+ /// Asks the page to move the visiting player to a destination inside the grid.
476
+ pub fn teleport_request(destination: Value) -> Result<Value, HostError> {
477
+ host_call("teleport_request", destination)
478
+ }
479
+
480
+ /// Places a mod-owned actor inside the grid. A live player's id is refused.
481
+ pub fn actor_spawn(actor: Value) -> Result<Value, HostError> {
482
+ host_call("actor_spawn", actor)
483
+ }
484
+
485
+ /// Moves a mod-owned actor of this session. A position outside the grid is refused.
486
+ pub fn actor_pose(actor_id: &str, pose: Value) -> Result<Value, HostError> {
487
+ host_call("actor_pose", json!({ "actorId": actor_id, "pose": pose }))
488
+ }
489
+
490
+ /// Removes a mod-owned actor of this session.
491
+ pub fn actor_despawn(actor_id: &str) -> Result<Value, HostError> {
492
+ host_call("actor_despawn", json!({ "actorId": actor_id }))
493
+ }
494
+
495
+ /// The mod's scene templates, kept in the page.
496
+ pub fn scene_catalog(catalog: Value) -> Result<Value, HostError> {
497
+ host_call("scene_catalog", catalog)
498
+ }
499
+
500
+ /// Template instances drawn in the page, inside the grid.
501
+ pub fn scene_instances(instances: Value) -> Result<Value, HostError> {
502
+ host_call("scene_instances", instances)
503
+ }
504
+
505
+ /// The visiting player's avatar tint.
506
+ pub fn avatar_appearance(appearance: Value) -> Result<Value, HostError> {
507
+ host_call("avatar_appearance", appearance)
508
+ }
509
+
510
+ /// The visiting player's own avatar state blob.
511
+ pub fn avatar_state_set(avatar_id: &str, state_base64: &str) -> Result<Value, HostError> {
512
+ host_call(
513
+ "avatar_state_set",
514
+ json!({ "avatarId": avatar_id, "stateBase64": state_base64 }),
515
+ )
516
+ }
517
+
518
+ /// Starts or stops the visiting player's microphone. The page still asks the browser.
519
+ pub fn voice_set(enabled: bool) -> Result<Value, HostError> {
520
+ host_call("voice_set", json!({ "enabled": enabled }))
521
+ }
522
+
523
+ /// Starts or stops the visiting player's camera. The page still asks the browser.
524
+ pub fn video_set(enabled: bool) -> Result<Value, HostError> {
525
+ host_call("video_set", json!({ "enabled": enabled }))
526
+ }
527
+
528
+ /// A ClientEvent from the visiting player's chunk, inside the grid.
529
+ pub fn send_client_event(chunk: (i64, i64, i64), payload: Value) -> Result<Value, HostError> {
530
+ host_call(
531
+ "send_client_event",
532
+ json!({ "chunkX": chunk.0, "chunkY": chunk.1, "chunkZ": chunk.2, "payload": payload }),
533
+ )
534
+ }
535
+
536
+ /// ClientEvents the visiting player's session has received since the last poll.
537
+ pub fn events_poll() -> Result<Value, HostError> {
538
+ host_call("events_poll", json!({}))
539
+ }
540
+
541
+ /// A spatial text packet from the visiting player's chunk.
542
+ pub fn send_text(chunk: (i64, i64, i64), text: &str) -> Result<Value, HostError> {
543
+ host_call(
544
+ "send_text",
545
+ json!({ "chunkX": chunk.0, "chunkY": chunk.1, "chunkZ": chunk.2, "text": text }),
546
+ )
547
+ }
548
+
549
+ /// A message from this session to one actor inside the grid.
550
+ pub fn send_actor_message(actor_id: &str, payload: Value) -> Result<Value, HostError> {
551
+ host_call("send_actor_message", json!({ "actorId": actor_id, "payload": payload }))
552
+ }
553
+
554
+ /// A channel message as the visiting player, on a channel they may send on.
555
+ pub fn send_channel_message(channel_id: &str, payload: Value) -> Result<Value, HostError> {
556
+ host_call(
557
+ "send_channel_message",
558
+ json!({ "channelId": channel_id, "payload": payload }),
559
+ )
560
+ }
561
+
562
+ // -- Egress ----------------------------------------------------------------------------
563
+
564
+ /// Sends a spatial message from a chunk inside the grid, as the visiting player's client.
565
+ /// `kind` is `actor`, `client_event`, `server_event` or `text`; `uuid_hex` is 64 hex
566
+ /// characters; `distance` is at most 8.
567
+ pub fn emit_spatial(
568
+ kind: &str,
569
+ chunk: (i64, i64, i64),
570
+ uuid_hex: &str,
571
+ payload_base64: &str,
572
+ distance: u8,
573
+ decay: u8,
574
+ ) -> Result<Value, HostError> {
575
+ host_call(
576
+ "emit_spatial",
577
+ json!({
578
+ "kind": kind,
579
+ "chunkX": chunk.0, "chunkY": chunk.1, "chunkZ": chunk.2,
580
+ "uuidHex": uuid_hex, "payloadBase64": payload_base64,
581
+ "distance": distance, "decay": decay,
582
+ }),
583
+ )
584
+ }
585
+
586
+ /// Posts to one of the grid's channels.
587
+ pub fn emit_channel(channel_id: &str, payload_base64: &str) -> Result<Value, HostError> {
588
+ host_call("emit_channel", json!({ "channelId": channel_id, "payloadBase64": payload_base64 }))
589
+ }
590
+
591
+ /// Publishes an event on the page's grid event bus, which the other CLIENT mods of this
592
+ /// grid on the same page receive through `on_event`. Nothing leaves the page.
593
+ pub fn emit_event(name: &str, payload: Value) -> Result<Value, HostError> {
594
+ host_call("emit_event", json!({ "name": name, "payload": payload }))
595
+ }
596
+
597
+ /// [`emit_event`] addressed to one mod on the bus by name, whether or not it listens for
598
+ /// `name`.
599
+ pub fn emit_event_to(target: &str, name: &str, payload: Value) -> Result<Value, HostError> {
600
+ host_call("emit_event", json!({ "name": name, "payload": payload, "target": target }))
601
+ }
602
+ }
603
+
604
+ /// Wires a CLIENT half's functions to the exports the browser calls: `init()`, `tick(dt_ms)`,
605
+ /// `handle_invoke(ptr, len) -> packed u64`, and, in the four-argument form, `on_event(ptr, len)`
606
+ /// for grid events from the page's bus. The three-argument form exports no `on_event`.
607
+ ///
608
+ /// ```ignore
609
+ /// use crowdy_client_sdk as crowdy;
610
+ ///
611
+ /// fn init() {}
612
+ /// fn tick(_dt_ms: u32) {
613
+ /// let _ = crowdy::api::hud_set(serde_json::json!({ "text": "hello" }));
614
+ /// }
615
+ /// fn invoke(payload: &[u8]) -> Vec<u8> { payload.to_vec() }
616
+ ///
617
+ /// crowdy::register_module!(init: init, tick: tick, invoke: invoke);
618
+ /// ```
619
+ #[macro_export]
620
+ macro_rules! register_module {
621
+ (init: $init:path, tick: $tick:path, invoke: $invoke:path $(,)?) => {
622
+ $crate::__register_module_core!($init, $tick, $invoke);
623
+ };
624
+ (init: $init:path, tick: $tick:path, invoke: $invoke:path, event: $event:path $(,)?) => {
625
+ $crate::__register_module_core!($init, $tick, $invoke);
626
+
627
+ #[export_name = "on_event"]
628
+ pub extern "C" fn __ck_export_on_event(ptr: *const u8, len: u32) {
629
+ let input = unsafe { $crate::input_slice(ptr, len) };
630
+ $event(input);
631
+ }
632
+ };
633
+ }
634
+
635
+ #[doc(hidden)]
636
+ #[macro_export]
637
+ macro_rules! __register_module_core {
638
+ ($init:path, $tick:path, $invoke:path) => {
639
+ #[export_name = "init"]
640
+ pub extern "C" fn __ck_export_init() {
641
+ $init();
642
+ }
643
+
644
+ #[export_name = "tick"]
645
+ pub extern "C" fn __ck_export_tick(dt_ms: u32) {
646
+ $tick(dt_ms);
647
+ }
648
+
649
+ #[export_name = "handle_invoke"]
650
+ pub extern "C" fn __ck_export_handle_invoke(ptr: *const u8, len: u32) -> u64 {
651
+ let input = unsafe { $crate::input_slice(ptr, len) };
652
+ let out: Vec<u8> = $invoke(input);
653
+ $crate::pack_return(out)
654
+ }
655
+ };
656
+ }
657
+
658
+ #[cfg(test)]
659
+ mod tests {
660
+ use super::*;
661
+
662
+ #[test]
663
+ fn host_call_round_trips_through_the_test_host() {
664
+ testhost::install(testhost::MockHost {
665
+ on_host_call: Some(Box::new(|name, args| {
666
+ assert_eq!(name, "chunk_get");
667
+ assert_eq!(args["x"], 3);
668
+ Ok(serde_json::json!({ "voxelsBase64": null }))
669
+ })),
670
+ ..Default::default()
671
+ });
672
+ let data = api::chunk_get(3, 0, -2).expect("answered");
673
+ assert!(data["voxelsBase64"].is_null());
674
+ }
675
+
676
+ #[test]
677
+ fn a_refusal_is_a_host_error() {
678
+ testhost::install(testhost::MockHost {
679
+ on_host_call: Some(Box::new(|_, _| {
680
+ Err(("denied".into(), Some("target is outside the player grid".into())))
681
+ })),
682
+ ..Default::default()
683
+ });
684
+ let err = api::voxel_set((99, 0, 0), (1, 2, 3), 7, None).unwrap_err();
685
+ assert_eq!(err.kind, "denied");
686
+ assert_eq!(err.to_string(), "denied: target is outside the player grid");
687
+ }
688
+
689
+ #[test]
690
+ fn state_and_log_use_the_test_host() {
691
+ testhost::reset();
692
+ assert!(state_get().is_empty());
693
+ assert!(state_set(b"hud"));
694
+ assert_eq!(state_get(), b"hud");
695
+ log(1, "ready");
696
+ testhost::with(|m| assert_eq!(m.logs, vec![(1, "ready".to_string())]));
697
+ }
698
+ }