@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.
- package/LICENSE-APACHE +176 -0
- package/LICENSE-MIT +21 -0
- package/README.md +76 -2
- package/checks/compute-toolchain/host-catalog.json +68 -0
- package/crates/ckx-sdk/Cargo.toml +17 -0
- package/crates/ckx-sdk/LICENSE-APACHE +176 -0
- package/crates/ckx-sdk/LICENSE-MIT +21 -0
- package/crates/ckx-sdk/README.md +47 -0
- package/crates/ckx-sdk/src/abi.rs +115 -0
- package/crates/ckx-sdk/src/cron.rs +717 -0
- package/crates/ckx-sdk/src/emit.rs +140 -0
- package/crates/ckx-sdk/src/lib.rs +588 -0
- package/crates/ckx-sdk/src/model.rs +482 -0
- package/crates/ckx-sdk/src/node.rs +942 -0
- package/crates/ckx-sdk/src/rt.rs +519 -0
- package/crates/crowdy-client-sdk/Cargo.toml +15 -0
- package/crates/crowdy-client-sdk/LICENSE-APACHE +176 -0
- package/crates/crowdy-client-sdk/LICENSE-MIT +21 -0
- package/crates/crowdy-client-sdk/README.md +25 -0
- package/crates/crowdy-client-sdk/src/lib.rs +698 -0
- package/dist/checks/src/build/client-capabilities.js +123 -0
- package/dist/checks/src/build/exec-source.js +343 -0
- package/dist/checks/src/build/rust-source-guard.js +165 -0
- package/dist/checks/src/build/wasm-inspect.js +249 -0
- package/dist/cli/src/build.js +99 -0
- package/dist/cli/src/crate.js +64 -0
- package/dist/cli/src/doctor.js +51 -0
- package/dist/cli/src/kit.js +66 -0
- package/dist/cli/src/main.js +109 -0
- package/dist/cli/src/new.js +74 -0
- package/dist/cli/src/tools.js +104 -0
- package/kit.pin +9 -0
- package/package.json +81 -4
- package/rust-toolchain.toml +6 -0
- package/starters/ckx.json +9 -0
- package/starters/grid-mod/Cargo.toml +13 -0
- package/starters/grid-mod/src/lib.rs +82 -0
- package/starters/matchmaker/Cargo.toml +13 -0
- package/starters/matchmaker/src/lib.rs +362 -0
- package/starters/npc-mobs/Cargo.toml +14 -0
- package/starters/npc-mobs/src/lib.rs +469 -0
- package/starters/session/Cargo.toml +13 -0
- package/starters/session/src/lib.rs +395 -0
- package/starters/world-tick/Cargo.toml +14 -0
- package/starters/world-tick/src/lib.rs +346 -0
- package/templates/client/Cargo.toml +16 -0
- package/templates/client/src/lib.rs +31 -0
- package/templates/hub/Cargo.toml +11 -0
- package/templates/hub/src/lib.rs +73 -0
- package/templates/spoke/Cargo.toml +11 -0
- package/templates/spoke/src/lib.rs +53 -0
- package/tools/instrument/Cargo.lock +140 -0
- package/tools/instrument/Cargo.toml +11 -0
- package/tools/instrument/src/main.rs +165 -0
- package/vendor.lock +167 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
//! Glue between the exports the macros generate and the developer's node type.
|
|
2
|
+
|
|
3
|
+
use std::alloc::{Layout, alloc as raw_alloc, dealloc};
|
|
4
|
+
use std::cell::UnsafeCell;
|
|
5
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
6
|
+
|
|
7
|
+
use serde::{Deserialize, Serialize};
|
|
8
|
+
|
|
9
|
+
use crate::{
|
|
10
|
+
Call, CallError, Caller, Ctx, Error, Hub, Kind, Presence, Result, Session, Spoke, Status, TopicMsg, WorldEvent, abi, decode, encode,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
pub const ABI_VERSION: u32 = 5;
|
|
14
|
+
|
|
15
|
+
/// Timers one hub may have pending.
|
|
16
|
+
pub const MAX_TIMERS: usize = 1024;
|
|
17
|
+
/// Topics one hub may watch.
|
|
18
|
+
pub const MAX_WATCHES: usize = 256;
|
|
19
|
+
/// Shortest timer period.
|
|
20
|
+
pub const MIN_TIMER_MS: u64 = 10;
|
|
21
|
+
|
|
22
|
+
/// Starts a snapshot that carries the platform's part (timers, watches) before the hub's own
|
|
23
|
+
/// bytes. MessagePack never starts with 0xc1, and a snapshot without it is all the hub's.
|
|
24
|
+
const ENVELOPE: &[u8] = b"\xc1ckx2";
|
|
25
|
+
|
|
26
|
+
/// A guest is single threaded; the host never calls in while a call is running, except
|
|
27
|
+
/// `ckx_alloc`, which touches none of these.
|
|
28
|
+
struct GuestCell<T>(UnsafeCell<T>);
|
|
29
|
+
|
|
30
|
+
unsafe impl<T> Sync for GuestCell<T> {}
|
|
31
|
+
|
|
32
|
+
impl<T> GuestCell<T> {
|
|
33
|
+
const fn new(v: T) -> Self {
|
|
34
|
+
Self(UnsafeCell::new(v))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[allow(clippy::mut_from_ref)]
|
|
38
|
+
fn get(&self) -> &mut T {
|
|
39
|
+
unsafe { &mut *self.0.get() }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
44
|
+
struct Timer {
|
|
45
|
+
/// Unix milliseconds.
|
|
46
|
+
at: u64,
|
|
47
|
+
/// 0 for a one-shot timer.
|
|
48
|
+
every: u64,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// What the platform keeps for a hub beside its state, snapshotted with it.
|
|
52
|
+
#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
53
|
+
struct Platform {
|
|
54
|
+
#[serde(default)]
|
|
55
|
+
timers: BTreeMap<String, Timer>,
|
|
56
|
+
#[serde(default)]
|
|
57
|
+
watches: BTreeSet<(String, String, String)>,
|
|
58
|
+
/// The wake deadline the host was last given; `u64::MAX` when unknown.
|
|
59
|
+
#[serde(skip)]
|
|
60
|
+
wake: u64,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
impl Platform {
|
|
64
|
+
fn next_wake(&self) -> u64 {
|
|
65
|
+
self.timers.values().map(|t| t.at).min().unwrap_or(0)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Timers due at `now`, earliest first. Each is rescheduled (or removed) before it runs,
|
|
69
|
+
/// so its handler may replace or cancel it. A repeating timer that fell behind (the hub was
|
|
70
|
+
/// stopped, or a handler ran long) runs once and continues one period from now.
|
|
71
|
+
fn take_due(&mut self, now: u64) -> Vec<String> {
|
|
72
|
+
let mut due: Vec<(u64, String)> = self.timers.iter().filter(|(_, t)| t.at <= now).map(|(n, t)| (t.at, n.clone())).collect();
|
|
73
|
+
due.sort();
|
|
74
|
+
for (_, name) in &due {
|
|
75
|
+
let t = self.timers.get_mut(name).expect("listed above");
|
|
76
|
+
if t.every == 0 {
|
|
77
|
+
self.timers.remove(name);
|
|
78
|
+
} else {
|
|
79
|
+
t.at = if t.at + t.every > now { t.at + t.every } else { now + t.every };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
due.into_iter().map(|(_, n)| n).collect()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static PLATFORM: GuestCell<Platform> = GuestCell::new(Platform { timers: BTreeMap::new(), watches: BTreeSet::new(), wake: 0 });
|
|
87
|
+
|
|
88
|
+
fn wrap(p: &Platform, state: &[u8]) -> Result<Vec<u8>> {
|
|
89
|
+
let head = encode(p)?;
|
|
90
|
+
let mut out = Vec::with_capacity(ENVELOPE.len() + 4 + head.len() + state.len());
|
|
91
|
+
out.extend_from_slice(ENVELOPE);
|
|
92
|
+
out.extend_from_slice(&(head.len() as u32).to_le_bytes());
|
|
93
|
+
out.extend_from_slice(&head);
|
|
94
|
+
out.extend_from_slice(state);
|
|
95
|
+
Ok(out)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
fn unwrap(snapshot: &[u8]) -> Result<(Platform, &[u8])> {
|
|
99
|
+
let Some(rest) = snapshot.strip_prefix(ENVELOPE) else {
|
|
100
|
+
return Ok((Platform::default(), snapshot));
|
|
101
|
+
};
|
|
102
|
+
let bad = || Error::new("malformed snapshot envelope");
|
|
103
|
+
let len = u32::from_le_bytes(rest.get(..4).ok_or_else(bad)?.try_into().unwrap()) as usize;
|
|
104
|
+
let head = rest.get(4..4 + len).ok_or_else(bad)?;
|
|
105
|
+
Ok((decode(head)?, &rest[4 + len..]))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Tells the host about the earliest timer, if that changed since it was last told.
|
|
109
|
+
fn sync_wake() {
|
|
110
|
+
let p = PLATFORM.get();
|
|
111
|
+
let next = p.next_wake();
|
|
112
|
+
if next != p.wake {
|
|
113
|
+
p.wake = next;
|
|
114
|
+
abi::wake_at(next);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fn check_name(name: &str) -> Result<()> {
|
|
119
|
+
if name.is_empty() || name.len() > 64 {
|
|
120
|
+
return Err(Error::new("timer names are 1 to 64 bytes"));
|
|
121
|
+
}
|
|
122
|
+
Ok(())
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
pub(crate) fn set_timer(ctx: &Ctx, name: &str, delay_ms: u64, every_ms: u64) -> Result<()> {
|
|
126
|
+
if ctx.kind != Kind::Hub {
|
|
127
|
+
return Err(Error::new("timers are for hubs"));
|
|
128
|
+
}
|
|
129
|
+
check_name(name)?;
|
|
130
|
+
let p = PLATFORM.get();
|
|
131
|
+
if !p.timers.contains_key(name) && p.timers.len() >= MAX_TIMERS {
|
|
132
|
+
return Err(Error(format!("a hub may have {MAX_TIMERS} timers")));
|
|
133
|
+
}
|
|
134
|
+
let every = if every_ms == 0 { 0 } else { every_ms.max(MIN_TIMER_MS) };
|
|
135
|
+
let first = if every == 0 { delay_ms } else { delay_ms.max(MIN_TIMER_MS) };
|
|
136
|
+
p.timers.insert(name.to_owned(), Timer { at: abi::now_ms() + first, every });
|
|
137
|
+
sync_wake();
|
|
138
|
+
Ok(())
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
pub(crate) fn cancel_timer(name: &str) -> bool {
|
|
142
|
+
let removed = PLATFORM.get().timers.remove(name).is_some();
|
|
143
|
+
sync_wake();
|
|
144
|
+
removed
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
pub(crate) fn timers() -> Vec<(String, u64)> {
|
|
148
|
+
PLATFORM.get().timers.iter().map(|(n, t)| (n.clone(), t.at)).collect()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
pub(crate) fn watch(ctx: &Ctx, node_type: &str, key: &str, topic: &str, on: bool) -> Result<(), CallError> {
|
|
152
|
+
let refuse = |message: &str| CallError { status: Status::BadRequest, message: message.into() };
|
|
153
|
+
if ctx.kind != Kind::Hub {
|
|
154
|
+
return Err(CallError { status: Status::Denied, message: "only hubs subscribe to topics".into() });
|
|
155
|
+
}
|
|
156
|
+
if topic.is_empty() || topic.len() > 255 || topic.starts_with('$') {
|
|
157
|
+
return Err(refuse("topics are 1 to 255 bytes and do not start with `$`"));
|
|
158
|
+
}
|
|
159
|
+
let entry = (node_type.to_owned(), key.to_owned(), topic.to_owned());
|
|
160
|
+
let p = PLATFORM.get();
|
|
161
|
+
if on && !p.watches.contains(&entry) && p.watches.len() >= MAX_WATCHES {
|
|
162
|
+
return Err(refuse(&format!("a hub may watch {MAX_WATCHES} topics")));
|
|
163
|
+
}
|
|
164
|
+
let result = import_result(abi::watch(&crate::request(node_type, key, topic, &[]), on)).map(|_| ());
|
|
165
|
+
let p = PLATFORM.get();
|
|
166
|
+
if on {
|
|
167
|
+
if result.is_ok() {
|
|
168
|
+
p.watches.insert(entry);
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
p.watches.remove(&entry);
|
|
172
|
+
}
|
|
173
|
+
result
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
trait DynNode {
|
|
177
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>>;
|
|
178
|
+
fn persist(&mut self, ctx: &Ctx) -> Result<Vec<u8>>;
|
|
179
|
+
fn evict(&mut self, ctx: &Ctx);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
pub trait Node: Sized + 'static {
|
|
183
|
+
fn init(ctx: &Ctx, seed: &[u8], snapshot: Option<(&[u8], u64)>) -> Result<Self>;
|
|
184
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>>;
|
|
185
|
+
fn persist(&mut self, ctx: &Ctx) -> Result<Vec<u8>>;
|
|
186
|
+
fn evict(&mut self, ctx: &Ctx);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
impl<N: Node> DynNode for N {
|
|
190
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
191
|
+
Node::handle(self, ctx, call)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
fn persist(&mut self, ctx: &Ctx) -> Result<Vec<u8>> {
|
|
195
|
+
Node::persist(self, ctx)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
fn evict(&mut self, ctx: &Ctx) {
|
|
199
|
+
Node::evict(self, ctx)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
pub struct HubNode<T: Hub>(T);
|
|
204
|
+
|
|
205
|
+
/// `[type str8][key str16][topic str8][payload...]`
|
|
206
|
+
fn parse_topic(b: &[u8]) -> Result<TopicMsg<'_>> {
|
|
207
|
+
let bad = || Error::new("malformed topic message");
|
|
208
|
+
let str_at = |at: usize, len: usize| std::str::from_utf8(b.get(at..at + len).ok_or_else(bad)?).map_err(|_| bad());
|
|
209
|
+
let tlen = *b.first().ok_or_else(bad)? as usize;
|
|
210
|
+
let node_type = str_at(1, tlen)?;
|
|
211
|
+
let at = 1 + tlen;
|
|
212
|
+
let klen = u16::from_le_bytes(b.get(at..at + 2).ok_or_else(bad)?.try_into().unwrap()) as usize;
|
|
213
|
+
let key = str_at(at + 2, klen)?;
|
|
214
|
+
let at = at + 2 + klen;
|
|
215
|
+
let tplen = *b.get(at).ok_or_else(bad)? as usize;
|
|
216
|
+
let topic = str_at(at + 1, tplen)?;
|
|
217
|
+
Ok(TopicMsg { node_type, key, topic, payload: &b[at + 1 + tplen..] })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
impl<T: Hub> HubNode<T> {
|
|
221
|
+
fn platform_call(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
222
|
+
use ckx_platform::*;
|
|
223
|
+
match (call.method, call.caller) {
|
|
224
|
+
(TIMER, Caller::Platform) => {
|
|
225
|
+
// The host cleared its deadline to make this call; always give it the next one.
|
|
226
|
+
PLATFORM.get().wake = u64::MAX;
|
|
227
|
+
for name in PLATFORM.get().take_due(abi::now_ms()) {
|
|
228
|
+
if let Err(e) = self.0.on_timer(ctx, &name) {
|
|
229
|
+
abi::log(0, &format!("timer `{name}`: {e}"));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
(TOPIC, Caller::Platform) => self.0.on_topic(ctx, parse_topic(call.payload)?)?,
|
|
234
|
+
(PRESENCE, Caller::Platform) => self.0.on_presence(ctx, &decode::<Presence>(call.payload)?)?,
|
|
235
|
+
(WORLD, Caller::Platform) => {
|
|
236
|
+
#[derive(Deserialize)]
|
|
237
|
+
struct Batch {
|
|
238
|
+
events: Vec<WorldEvent>,
|
|
239
|
+
}
|
|
240
|
+
self.0.on_world(ctx, &decode::<Batch>(call.payload)?.events)?
|
|
241
|
+
}
|
|
242
|
+
(SESSION, Caller::Player(p)) => {
|
|
243
|
+
let session = match call.payload {
|
|
244
|
+
[SESSION_JOINED] => Session::Joined,
|
|
245
|
+
[SESSION_LEFT] => Session::Left,
|
|
246
|
+
_ => return Err(Error::new("malformed session event")),
|
|
247
|
+
};
|
|
248
|
+
self.0.on_session(ctx, p, session)?
|
|
249
|
+
}
|
|
250
|
+
(m, c) => return Err(Error(format!("no platform event `{m}` from {c:?}"))),
|
|
251
|
+
}
|
|
252
|
+
Ok(Vec::new())
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/// Reserved method names, as `ckx_proto::platform` defines them.
|
|
257
|
+
mod ckx_platform {
|
|
258
|
+
pub const TIMER: &str = "$timer";
|
|
259
|
+
pub const TOPIC: &str = "$topic";
|
|
260
|
+
pub const PRESENCE: &str = "$presence";
|
|
261
|
+
pub const WORLD: &str = "$world";
|
|
262
|
+
pub const SESSION: &str = "$session";
|
|
263
|
+
pub const SESSION_JOINED: u8 = 1;
|
|
264
|
+
pub const SESSION_LEFT: u8 = 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
impl<T: Hub> Node for HubNode<T> {
|
|
268
|
+
fn init(ctx: &Ctx, seed: &[u8], snapshot: Option<(&[u8], u64)>) -> Result<Self> {
|
|
269
|
+
let hub = match snapshot {
|
|
270
|
+
Some((bytes, version)) => {
|
|
271
|
+
let (platform, state) = unwrap(bytes)?;
|
|
272
|
+
*PLATFORM.get() = platform;
|
|
273
|
+
T::load(ctx, state, version)?
|
|
274
|
+
}
|
|
275
|
+
None => {
|
|
276
|
+
*PLATFORM.get() = Platform::default();
|
|
277
|
+
T::spawn(ctx, seed)?
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
let watches: Vec<(String, String, String)> = PLATFORM.get().watches.iter().cloned().collect();
|
|
281
|
+
for (node_type, key, topic) in watches {
|
|
282
|
+
let req = crate::request(&node_type, &key, &topic, &[]);
|
|
283
|
+
if let Err(e) = import_result(abi::watch(&req, true)) {
|
|
284
|
+
abi::log(1, &format!("could not watch `{topic}` on {node_type}/{key} again: {e}"));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
PLATFORM.get().wake = u64::MAX;
|
|
288
|
+
sync_wake();
|
|
289
|
+
Ok(Self(hub))
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
293
|
+
let out = if call.method.starts_with('$') { self.platform_call(ctx, call) } else { self.0.handle(ctx, call) };
|
|
294
|
+
sync_wake();
|
|
295
|
+
out
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
fn persist(&mut self, ctx: &Ctx) -> Result<Vec<u8>> {
|
|
299
|
+
let state = self.0.persist(ctx)?;
|
|
300
|
+
wrap(PLATFORM.get(), &state)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
fn evict(&mut self, ctx: &Ctx) {
|
|
304
|
+
self.0.evict(ctx)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
pub struct SpokeNode<T: Spoke>(T);
|
|
309
|
+
|
|
310
|
+
impl<T: Spoke> Node for SpokeNode<T> {
|
|
311
|
+
fn init(ctx: &Ctx, _seed: &[u8], _snapshot: Option<(&[u8], u64)>) -> Result<Self> {
|
|
312
|
+
Ok(Self(T::start(ctx)?))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
316
|
+
if call.method.starts_with('$') {
|
|
317
|
+
return Err(Error(format!("spokes receive no platform events (`{}`)", call.method)));
|
|
318
|
+
}
|
|
319
|
+
self.0.handle(ctx, call)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
|
|
323
|
+
Ok(Vec::new())
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
fn evict(&mut self, _ctx: &Ctx) {}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
static NODE: GuestCell<Option<(Ctx, Box<dyn DynNode>)>> = GuestCell::new(None);
|
|
330
|
+
static OUT: GuestCell<Vec<u8>> = GuestCell::new(Vec::new());
|
|
331
|
+
|
|
332
|
+
fn layout(len: u32) -> Layout {
|
|
333
|
+
Layout::from_size_align(len as usize, 1).expect("valid layout")
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
pub fn alloc(len: u32) -> u32 {
|
|
337
|
+
if len == 0 {
|
|
338
|
+
return 1;
|
|
339
|
+
}
|
|
340
|
+
let ptr = unsafe { raw_alloc(layout(len)) };
|
|
341
|
+
if ptr.is_null() {
|
|
342
|
+
std::process::abort();
|
|
343
|
+
}
|
|
344
|
+
ptr as u32
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
fn free(ptr: u32, len: u32) {
|
|
348
|
+
if len != 0 {
|
|
349
|
+
unsafe { dealloc(ptr as *mut u8, layout(len)) }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/// Copies a host-written buffer out and frees it.
|
|
354
|
+
fn take(ptr: u32, len: u32) -> Vec<u8> {
|
|
355
|
+
if len == 0 {
|
|
356
|
+
return Vec::new();
|
|
357
|
+
}
|
|
358
|
+
let v = unsafe { std::slice::from_raw_parts(ptr as *const u8, len as usize) }.to_vec();
|
|
359
|
+
free(ptr, len);
|
|
360
|
+
v
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
fn pack(ptr: u32, len: u32) -> u64 {
|
|
364
|
+
((ptr as u64) << 32) | len as u64
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/// Writes `[status][payload]` into the reusable output buffer and returns it packed.
|
|
368
|
+
fn respond(result: Result<Vec<u8>>) -> u64 {
|
|
369
|
+
let out = OUT.get();
|
|
370
|
+
out.clear();
|
|
371
|
+
match result {
|
|
372
|
+
Ok(bytes) => {
|
|
373
|
+
out.push(0);
|
|
374
|
+
out.extend_from_slice(&bytes);
|
|
375
|
+
}
|
|
376
|
+
Err(Error(msg)) => {
|
|
377
|
+
out.push(1);
|
|
378
|
+
out.extend_from_slice(msg.as_bytes());
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
pack(out.as_ptr() as u32, out.len() as u32)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/// Unpacks a result the host returned from an import.
|
|
385
|
+
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 {
|
|
388
|
+
return Err(CallError { status: Status::Internal, message: "no reply from the host".into() });
|
|
389
|
+
}
|
|
390
|
+
let buf = take(ptr, len);
|
|
391
|
+
match Status::from_u8(buf[0]) {
|
|
392
|
+
Status::Ok => Ok(buf[1..].to_vec()),
|
|
393
|
+
status => Err(CallError { status, message: String::from_utf8_lossy(&buf[1..]).into_owned() }),
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
fn parse_ctx(b: &[u8]) -> Result<Ctx> {
|
|
398
|
+
let bad = || Error::new("malformed instance context");
|
|
399
|
+
let u64_at = |at: usize| -> Result<u64> { Ok(u64::from_le_bytes(b.get(at..at + 8).ok_or_else(bad)?.try_into().unwrap())) };
|
|
400
|
+
let kind = match b.get(32).ok_or_else(bad)? {
|
|
401
|
+
0 => Kind::Hub,
|
|
402
|
+
_ => Kind::Spoke,
|
|
403
|
+
};
|
|
404
|
+
let tlen = *b.get(33).ok_or_else(bad)? as usize;
|
|
405
|
+
let node_type = String::from_utf8(b.get(34..34 + tlen).ok_or_else(bad)?.to_vec()).map_err(|_| bad())?;
|
|
406
|
+
let at = 34 + tlen;
|
|
407
|
+
let klen = u16::from_le_bytes(b.get(at..at + 2).ok_or_else(bad)?.try_into().unwrap()) as usize;
|
|
408
|
+
let key = String::from_utf8(b.get(at + 2..at + 2 + klen).ok_or_else(bad)?.to_vec()).map_err(|_| bad())?;
|
|
409
|
+
Ok(Ctx { app: u64_at(0)?, instance: u64_at(8)?, epoch: u64_at(16)?, version: u64_at(24)?, kind, node_type, key })
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#[allow(clippy::too_many_arguments)]
|
|
413
|
+
pub fn init<N: Node>(
|
|
414
|
+
ctx_ptr: u32,
|
|
415
|
+
ctx_len: u32,
|
|
416
|
+
seed_ptr: u32,
|
|
417
|
+
seed_len: u32,
|
|
418
|
+
snap_ptr: u32,
|
|
419
|
+
snap_len: u32,
|
|
420
|
+
snap_version: u64,
|
|
421
|
+
has_snap: u32,
|
|
422
|
+
) -> u64 {
|
|
423
|
+
let ctx_bytes = take(ctx_ptr, ctx_len);
|
|
424
|
+
let seed = take(seed_ptr, seed_len);
|
|
425
|
+
let snap = take(snap_ptr, snap_len);
|
|
426
|
+
let result = parse_ctx(&ctx_bytes).and_then(|ctx| {
|
|
427
|
+
let snapshot = (has_snap != 0).then_some((snap.as_slice(), snap_version));
|
|
428
|
+
let node = N::init(&ctx, &seed, snapshot)?;
|
|
429
|
+
*NODE.get() = Some((ctx, Box::new(node)));
|
|
430
|
+
Ok(Vec::new())
|
|
431
|
+
});
|
|
432
|
+
respond(result)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
pub fn handle(caller_kind: u32, caller_id: u64, buf_ptr: u32, method_len: u32, payload_len: u32) -> u64 {
|
|
436
|
+
let buf = take(buf_ptr, method_len + payload_len);
|
|
437
|
+
let (method, payload) = buf.split_at(method_len as usize);
|
|
438
|
+
let Ok(method) = std::str::from_utf8(method) else {
|
|
439
|
+
return respond(Err(Error::new("method name is not UTF-8")));
|
|
440
|
+
};
|
|
441
|
+
let caller = match caller_kind {
|
|
442
|
+
0 => Caller::Platform,
|
|
443
|
+
1 => Caller::Instance(caller_id),
|
|
444
|
+
2 => Caller::Player(caller_id),
|
|
445
|
+
3 => Caller::Developer(caller_id),
|
|
446
|
+
// Never the platform: a caller this SDK does not know is refused, not trusted.
|
|
447
|
+
_ => return respond(Err(Error::new("unknown caller kind"))),
|
|
448
|
+
};
|
|
449
|
+
let Some((ctx, node)) = NODE.get().as_mut() else {
|
|
450
|
+
return respond(Err(Error::new("instance not initialised")));
|
|
451
|
+
};
|
|
452
|
+
respond(node.handle(ctx, Call { caller, method, payload }))
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
pub fn persist() -> u64 {
|
|
456
|
+
match NODE.get().as_mut() {
|
|
457
|
+
Some((ctx, node)) => respond(node.persist(ctx)),
|
|
458
|
+
None => respond(Err(Error::new("instance not initialised"))),
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
pub fn evict() {
|
|
463
|
+
if let Some((ctx, node)) = NODE.get().as_mut() {
|
|
464
|
+
node.evict(ctx);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#[cfg(test)]
|
|
469
|
+
mod tests {
|
|
470
|
+
use super::*;
|
|
471
|
+
|
|
472
|
+
fn timer(at: u64, every: u64) -> Timer {
|
|
473
|
+
Timer { at, every }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
#[test]
|
|
477
|
+
fn the_envelope_carries_timers_and_watches_and_old_snapshots_pass_through() {
|
|
478
|
+
let mut p = Platform::default();
|
|
479
|
+
p.timers.insert("tick".into(), timer(1_000, 100));
|
|
480
|
+
p.watches.insert(("arena".into(), "m1".into(), "hp".into()));
|
|
481
|
+
let snap = wrap(&p, b"state").unwrap();
|
|
482
|
+
let (back, state) = unwrap(&snap).unwrap();
|
|
483
|
+
assert_eq!((back, state), (p, &b"state"[..]));
|
|
484
|
+
|
|
485
|
+
let v1 = encode(&42u32).unwrap();
|
|
486
|
+
let (none, state) = unwrap(&v1).unwrap();
|
|
487
|
+
assert_eq!((none, state), (Platform::default(), &v1[..]));
|
|
488
|
+
assert!(unwrap(b"\xc1ckx2\xff\xff\xff\xff").is_err());
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#[test]
|
|
492
|
+
fn due_timers_run_in_order_and_repeat_on_schedule() {
|
|
493
|
+
let mut p = Platform::default();
|
|
494
|
+
p.timers.insert("b".into(), timer(200, 0));
|
|
495
|
+
p.timers.insert("a".into(), timer(150, 100));
|
|
496
|
+
p.timers.insert("later".into(), timer(900, 0));
|
|
497
|
+
assert_eq!(p.take_due(210), vec!["a".to_owned(), "b".to_owned()]);
|
|
498
|
+
assert_eq!(p.timers["a"], timer(250, 100), "keeps its schedule while it keeps up");
|
|
499
|
+
assert!(!p.timers.contains_key("b"), "one-shot timers are removed");
|
|
500
|
+
assert_eq!(p.next_wake(), 250);
|
|
501
|
+
assert!(p.take_due(240).is_empty());
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
#[test]
|
|
505
|
+
fn an_overdue_repeating_timer_runs_once_then_continues_from_now() {
|
|
506
|
+
let mut p = Platform::default();
|
|
507
|
+
p.timers.insert("tick".into(), timer(1_000, 100));
|
|
508
|
+
assert_eq!(p.take_due(60_000), vec!["tick".to_owned()]);
|
|
509
|
+
assert_eq!(p.timers["tick"], timer(60_100, 100));
|
|
510
|
+
assert!(p.take_due(60_050).is_empty());
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
#[test]
|
|
514
|
+
fn topic_messages_parse_the_request_layout() {
|
|
515
|
+
let m = parse_topic(b"\x05arena\x02\x00m1\x02hp\x09").unwrap();
|
|
516
|
+
assert_eq!((m.node_type, m.key, m.topic, m.payload), ("arena", "m1", "hp", &b"\x09"[..]));
|
|
517
|
+
assert!(parse_topic(b"\x05are").is_err());
|
|
518
|
+
}
|
|
519
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "crowdy-client-sdk"
|
|
3
|
+
version = "0.1.0-dev.1"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
description = "Guest SDK for the CLIENT half of a ck-exec mod: browser WASM (ck ABI v0 + the client host calls)."
|
|
6
|
+
license = "MIT OR Apache-2.0"
|
|
7
|
+
homepage = "https://docs.crowdedkingdoms.com/exec/client-halves"
|
|
8
|
+
repository = "https://github.com/CrowdedKingdoms/ckx-kit"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
keywords = ["crowded-kingdoms", "ck-exec", "wasm", "gamedev", "sdk"]
|
|
11
|
+
categories = ["game-development", "wasm"]
|
|
12
|
+
|
|
13
|
+
[dependencies]
|
|
14
|
+
serde = { version = "1", features = ["derive"] }
|
|
15
|
+
serde_json = "1"
|