@crowdedkingdoms/ckx-kit 0.1.0-dev.1 → 0.2.0-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.
- package/README.md +29 -5
- package/crates/ckx-sdk/Cargo.toml +1 -1
- package/crates/ckx-sdk/src/abi.rs +106 -27
- package/crates/ckx-sdk/src/cron.rs +1 -1
- package/crates/ckx-sdk/src/lib.rs +26 -0
- package/crates/ckx-sdk/src/limits.rs +52 -0
- package/crates/ckx-sdk/src/model.rs +1 -1
- package/crates/ckx-sdk/src/node.rs +28 -31
- package/crates/ckx-sdk/src/rt.rs +80 -9
- package/crates/ckx-sdk/src/testing.rs +1442 -0
- package/kit.pin +4 -4
- package/package.json +2 -2
- package/starters/grid-mod/Cargo.toml +1 -1
- package/starters/grid-mod/src/lib.rs +23 -0
- package/starters/matchmaker/Cargo.toml +1 -1
- package/starters/npc-mobs/Cargo.toml +1 -1
- package/starters/session/Cargo.toml +1 -1
- package/starters/world-tick/Cargo.toml +1 -1
- package/templates/hub/src/lib.rs +15 -18
- package/templates/spoke/src/lib.rs +5 -5
- package/vendor.lock +1 -1
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 #
|
|
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.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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,5 +1,6 @@
|
|
|
1
|
-
//! Host imports
|
|
2
|
-
//!
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
101
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
114
|
-
|
|
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
|
|
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
|
|
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`.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
694
|
+
struct FakeNode {
|
|
704
695
|
sent: Vec<(String, Value)>,
|
|
705
696
|
replies: VecDeque<Value>,
|
|
706
697
|
}
|
|
707
698
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
716
|
-
|
|
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
|
-
|
|
723
|
-
let
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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 {
|
package/crates/ckx-sdk/src/rt.rs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
///
|
|
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
|
-
|
|
330
|
-
|
|
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 (
|
|
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())) };
|