@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 +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/crates/crowdy-client-sdk/Cargo.toml +1 -1
- package/kit.pin +4 -4
- package/package.json +1 -1
- 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
|
@@ -0,0 +1,1442 @@
|
|
|
1
|
+
//! Unit tests for hubs, spokes and mods, run natively with `cargo test`.
|
|
2
|
+
//!
|
|
3
|
+
//! [`TestHub`] and [`TestSpoke`] run a node on the test's thread the way a sandbox runs it:
|
|
4
|
+
//! calls from players, developers and other instances; a hub's platform events (timers on a
|
|
5
|
+
//! clock the test moves, topics it watches, presence, sessions, and a mod's world events); and
|
|
6
|
+
//! a snapshot and a start from it. The platform's side is a [`FakePlatform`]. It keeps what the
|
|
7
|
+
//! node logged, published, sent and emitted; holds each call to the sandbox's bounds
|
|
8
|
+
//! ([`crate::limits`]); answers calls to other types and node API operations with what the test
|
|
9
|
+
//! scripts; and serves a mod its [`FakeGrid`] under the node API's rules for mods.
|
|
10
|
+
//!
|
|
11
|
+
//! ```
|
|
12
|
+
//! use ckx_sdk::prelude::*;
|
|
13
|
+
//! use ckx_sdk::testing::TestHub;
|
|
14
|
+
//!
|
|
15
|
+
//! #[derive(Default, Serialize, Deserialize)]
|
|
16
|
+
//! struct Counter {
|
|
17
|
+
//! total: i64,
|
|
18
|
+
//! }
|
|
19
|
+
//!
|
|
20
|
+
//! impl Hub for Counter {
|
|
21
|
+
//! fn spawn(_ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
|
|
22
|
+
//! Ok(Self::default())
|
|
23
|
+
//! }
|
|
24
|
+
//! fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
|
|
25
|
+
//! decode(snapshot)
|
|
26
|
+
//! }
|
|
27
|
+
//! fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
|
|
28
|
+
//! encode(self)
|
|
29
|
+
//! }
|
|
30
|
+
//! fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
31
|
+
//! match call.method {
|
|
32
|
+
//! "add" => {
|
|
33
|
+
//! self.total += call.decode::<i64>()?;
|
|
34
|
+
//! ctx.publish("total", &encode(&self.total)?);
|
|
35
|
+
//! encode(&self.total)
|
|
36
|
+
//! }
|
|
37
|
+
//! other => Err(Error::unknown_method(other)),
|
|
38
|
+
//! }
|
|
39
|
+
//! }
|
|
40
|
+
//! }
|
|
41
|
+
//!
|
|
42
|
+
//! let mut hub = TestHub::<Counter>::spawn("counter", "a").unwrap();
|
|
43
|
+
//! let total: i64 = hub.call_typed(Caller::Player(42), "add", &5).unwrap();
|
|
44
|
+
//! assert_eq!(total, 5);
|
|
45
|
+
//! assert_eq!(hub.published()[0].0, "total");
|
|
46
|
+
//! hub.restart().unwrap();
|
|
47
|
+
//! assert_eq!(hub.hub().total, 5);
|
|
48
|
+
//! ```
|
|
49
|
+
//!
|
|
50
|
+
//! Fuel, memory and deadlines are the platform's alone: they meter the built module, which the
|
|
51
|
+
//! `ckx-kit` command line builds and checks. A test does not see the app's manifest either, so
|
|
52
|
+
//! it holds an app's own types to no call policy and no node API scopes, and it applies no node
|
|
53
|
+
//! API rate limits.
|
|
54
|
+
//!
|
|
55
|
+
//! A thread runs one node at a time, as a sandbox runs one call at a time: drop a `TestHub` or
|
|
56
|
+
//! `TestSpoke` before starting the next on the same thread. `cargo test` runs each test on a
|
|
57
|
+
//! thread of its own.
|
|
58
|
+
|
|
59
|
+
use std::cell::{Ref, RefCell, RefMut};
|
|
60
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
61
|
+
use std::ops::Deref;
|
|
62
|
+
use std::rc::Rc;
|
|
63
|
+
|
|
64
|
+
use base64::Engine as _;
|
|
65
|
+
use base64::alphabet;
|
|
66
|
+
use base64::engine::general_purpose::STANDARD as B64;
|
|
67
|
+
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
|
|
68
|
+
use serde::Serialize;
|
|
69
|
+
use serde::de::DeserializeOwned;
|
|
70
|
+
use serde_json::{Value, json};
|
|
71
|
+
|
|
72
|
+
use crate::abi::native::{self, Host};
|
|
73
|
+
use crate::node::{Actor, Chunk, ChunkPos, Voxel};
|
|
74
|
+
use crate::rt::{HubNode, Node, SpokeNode, ckx_platform as platform};
|
|
75
|
+
use crate::{Call, CallError, Caller, Ctx, Error, Hub, Kind, Level, Presence, Session, Spoke, Status, WorldEvent, decode, encode, limits};
|
|
76
|
+
|
|
77
|
+
/// Where a test's clock starts: 2026-01-01T00:00:00Z, in Unix milliseconds.
|
|
78
|
+
pub const START_MS: u64 = 1_767_225_600_000;
|
|
79
|
+
/// The app every test node belongs to.
|
|
80
|
+
pub const TEST_APP: u64 = 1;
|
|
81
|
+
|
|
82
|
+
const SENT_ENOUGH: &str = "this call sent as much as one call may";
|
|
83
|
+
/// Timer calls one advance may make before the test hears its timers never settle.
|
|
84
|
+
const MAX_TIMER_CALLS: u32 = 1_000_000;
|
|
85
|
+
|
|
86
|
+
/// A message the node sent another instance: a call it waited on, or a one-way send.
|
|
87
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
88
|
+
pub struct Message {
|
|
89
|
+
pub node_type: String,
|
|
90
|
+
pub key: String,
|
|
91
|
+
pub method: String,
|
|
92
|
+
pub payload: Vec<u8>,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
impl Message {
|
|
96
|
+
pub fn decode<T: DeserializeOwned>(&self) -> crate::Result<T> {
|
|
97
|
+
decode(&self.payload)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// A line the node logged, as the platform keeps it.
|
|
102
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
103
|
+
pub struct LogLine {
|
|
104
|
+
pub level: Level,
|
|
105
|
+
pub text: String,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type CallAnswer = Box<dyn FnMut(&Message) -> Result<Vec<u8>, CallError>>;
|
|
109
|
+
type NodeAnswer = Box<dyn FnMut(&Value) -> Result<Value, CallError>>;
|
|
110
|
+
|
|
111
|
+
struct State {
|
|
112
|
+
node_type: String,
|
|
113
|
+
hub: bool,
|
|
114
|
+
now: u64,
|
|
115
|
+
wake: Option<u64>,
|
|
116
|
+
logs: Vec<LogLine>,
|
|
117
|
+
published: Vec<(String, Vec<u8>)>,
|
|
118
|
+
calls: Vec<Message>,
|
|
119
|
+
sent: Vec<Message>,
|
|
120
|
+
emitted: Vec<Vec<u8>>,
|
|
121
|
+
dropped: u32,
|
|
122
|
+
node_requests: Vec<(String, Value)>,
|
|
123
|
+
watching: BTreeSet<(String, String, String)>,
|
|
124
|
+
persist_asked: u32,
|
|
125
|
+
logs_left: u32,
|
|
126
|
+
out_msgs_left: u32,
|
|
127
|
+
out_bytes_left: u64,
|
|
128
|
+
call_answers: Vec<(String, CallAnswer)>,
|
|
129
|
+
node_answers: Vec<(String, NodeAnswer)>,
|
|
130
|
+
grid: Option<FakeGrid>,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
impl State {
|
|
134
|
+
fn is_mod(&self) -> bool {
|
|
135
|
+
self.node_type.starts_with(limits::mods::TYPE_PREFIX)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// What a call into the node may do, renewed for each one.
|
|
139
|
+
fn begin(&mut self) {
|
|
140
|
+
self.logs_left = limits::LOGS_PER_CALL;
|
|
141
|
+
self.out_msgs_left = limits::OUT_MSGS_PER_CALL;
|
|
142
|
+
self.out_bytes_left = limits::OUT_BYTES_PER_CALL;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
fn spend(&mut self, bytes: usize) -> bool {
|
|
146
|
+
let bytes = bytes as u64;
|
|
147
|
+
if self.out_msgs_left == 0 || self.out_bytes_left < bytes {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
self.out_msgs_left -= 1;
|
|
151
|
+
self.out_bytes_left -= bytes;
|
|
152
|
+
true
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
fn refuse_mod_call(&self, target: &str) -> Vec<u8> {
|
|
156
|
+
reply(Status::Denied, format!("`{}` may not call `{target}`", self.node_type).as_bytes())
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fn reply(status: Status, payload: &[u8]) -> Vec<u8> {
|
|
161
|
+
let mut out = Vec::with_capacity(1 + payload.len());
|
|
162
|
+
out.push(status.as_u8());
|
|
163
|
+
out.extend_from_slice(payload);
|
|
164
|
+
out
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
fn answered(result: Result<Vec<u8>, CallError>) -> Vec<u8> {
|
|
168
|
+
match result {
|
|
169
|
+
Ok(bytes) => reply(Status::Ok, &bytes),
|
|
170
|
+
Err(e) => reply(e.status, e.message.as_bytes()),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// What the sandbox ends the call for: the test fails here, where the module would trap.
|
|
175
|
+
fn trap(why: String) -> ! {
|
|
176
|
+
panic!("the sandbox traps this call: {why}")
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fn check_request(req: &[u8]) {
|
|
180
|
+
if req.len() > limits::MAX_PAYLOAD + 1024 {
|
|
181
|
+
trap(format!("a request of {} bytes is over {}", req.len(), limits::MAX_PAYLOAD + 1024));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// `[type str8][key str16][method str8][payload...]`
|
|
186
|
+
fn parse_request(req: &[u8]) -> Option<Message> {
|
|
187
|
+
let tlen = *req.first()? as usize;
|
|
188
|
+
let node_type = std::str::from_utf8(req.get(1..1 + tlen)?).ok()?.to_owned();
|
|
189
|
+
let at = 1 + tlen;
|
|
190
|
+
let klen = u16::from_le_bytes(req.get(at..at + 2)?.try_into().ok()?) as usize;
|
|
191
|
+
let key = std::str::from_utf8(req.get(at + 2..at + 2 + klen)?).ok()?.to_owned();
|
|
192
|
+
let at = at + 2 + klen;
|
|
193
|
+
let mlen = *req.get(at)? as usize;
|
|
194
|
+
let method = std::str::from_utf8(req.get(at + 1..at + 1 + mlen)?).ok()?.to_owned();
|
|
195
|
+
let payload = req.get(at + 1 + mlen..)?.to_vec();
|
|
196
|
+
Some(Message { node_type, key, method, payload })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
fn level(v: u32) -> Level {
|
|
200
|
+
match v {
|
|
201
|
+
0 => Level::Error,
|
|
202
|
+
1 => Level::Warn,
|
|
203
|
+
2 => Level::Info,
|
|
204
|
+
_ => Level::Debug,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
struct Shared(Rc<RefCell<State>>);
|
|
209
|
+
|
|
210
|
+
impl Host for Shared {
|
|
211
|
+
fn log(&mut self, level_: u32, msg: &str) {
|
|
212
|
+
let mut s = self.0.borrow_mut();
|
|
213
|
+
if s.logs_left == 0 {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
s.logs_left -= 1;
|
|
217
|
+
let text = String::from_utf8_lossy(&msg.as_bytes()[..msg.len().min(limits::MAX_LOG_BYTES)]).into_owned();
|
|
218
|
+
s.logs.push(LogLine { level: level(level_), text });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
fn now_ms(&mut self) -> u64 {
|
|
222
|
+
self.0.borrow().now
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
fn call(&mut self, req: &[u8], _deadline_ms: u32) -> Vec<u8> {
|
|
226
|
+
check_request(req);
|
|
227
|
+
let Some(m) = parse_request(req) else {
|
|
228
|
+
return reply(Status::BadRequest, b"malformed request");
|
|
229
|
+
};
|
|
230
|
+
let mut s = self.0.borrow_mut();
|
|
231
|
+
if !s.spend(m.payload.len()) {
|
|
232
|
+
return reply(Status::Busy, SENT_ENOUGH.as_bytes());
|
|
233
|
+
}
|
|
234
|
+
if s.is_mod() {
|
|
235
|
+
return s.refuse_mod_call(&m.node_type);
|
|
236
|
+
}
|
|
237
|
+
s.calls.push(m.clone());
|
|
238
|
+
let result = match s.call_answers.iter_mut().rev().find(|(t, _)| *t == m.node_type || t == "*") {
|
|
239
|
+
Some((_, answer)) => answer(&m),
|
|
240
|
+
None => Err(CallError { status: Status::NotFound, message: format!("the test answers no calls to `{}`", m.node_type) }),
|
|
241
|
+
};
|
|
242
|
+
answered(result.and_then(|bytes| {
|
|
243
|
+
if bytes.len() > limits::MAX_PAYLOAD {
|
|
244
|
+
let message = format!("the handler's reply of {} bytes exceeds {}", bytes.len(), limits::MAX_PAYLOAD);
|
|
245
|
+
return Err(CallError { status: Status::Internal, message });
|
|
246
|
+
}
|
|
247
|
+
Ok(bytes)
|
|
248
|
+
}))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fn send(&mut self, req: &[u8]) -> u32 {
|
|
252
|
+
check_request(req);
|
|
253
|
+
let Some(m) = parse_request(req) else {
|
|
254
|
+
return Status::BadRequest.as_u8().into();
|
|
255
|
+
};
|
|
256
|
+
let mut s = self.0.borrow_mut();
|
|
257
|
+
if !s.spend(m.payload.len()) {
|
|
258
|
+
return Status::Busy.as_u8().into();
|
|
259
|
+
}
|
|
260
|
+
if s.is_mod() {
|
|
261
|
+
s.dropped += 1;
|
|
262
|
+
} else {
|
|
263
|
+
s.sent.push(m);
|
|
264
|
+
}
|
|
265
|
+
0
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fn publish(&mut self, topic: &str, payload: &[u8]) {
|
|
269
|
+
if topic.len() > 255 {
|
|
270
|
+
trap(format!("a topic of {} bytes is over 255", topic.len()));
|
|
271
|
+
}
|
|
272
|
+
if payload.len() > limits::MAX_PAYLOAD {
|
|
273
|
+
trap(format!("a payload of {} bytes is over {}", payload.len(), limits::MAX_PAYLOAD));
|
|
274
|
+
}
|
|
275
|
+
let mut s = self.0.borrow_mut();
|
|
276
|
+
if s.spend(payload.len()) {
|
|
277
|
+
s.published.push((topic.to_owned(), payload.to_vec()));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
fn node(&mut self, op: &str, payload: &[u8], _deadline_ms: u32) -> Vec<u8> {
|
|
282
|
+
if op.len() > 255 {
|
|
283
|
+
trap(format!("an operation name of {} bytes is over 255", op.len()));
|
|
284
|
+
}
|
|
285
|
+
if payload.len() > limits::MAX_PAYLOAD {
|
|
286
|
+
trap(format!("a payload of {} bytes is over {}", payload.len(), limits::MAX_PAYLOAD));
|
|
287
|
+
}
|
|
288
|
+
let mut s = self.0.borrow_mut();
|
|
289
|
+
if !s.spend(payload.len()) {
|
|
290
|
+
return reply(Status::Busy, SENT_ENOUGH.as_bytes());
|
|
291
|
+
}
|
|
292
|
+
let body: Value = serde_json::from_slice(payload).unwrap_or(Value::Null);
|
|
293
|
+
s.node_requests.push((op.to_owned(), body.clone()));
|
|
294
|
+
let now = s.now;
|
|
295
|
+
let result = match s.grid.as_mut() {
|
|
296
|
+
Some(grid) => grid.answer(op, &body, now),
|
|
297
|
+
None => match s.node_answers.iter_mut().rev().find(|(o, _)| o == op || o == "*") {
|
|
298
|
+
Some((_, answer)) => answer(&body),
|
|
299
|
+
None => Err(CallError { status: Status::NotFound, message: format!("the test answers no node API operation `{op}`") }),
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
answered(result.map(|v| serde_json::to_vec(&v).expect("a JSON value encodes")))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
fn wake_at(&mut self, at_ms: u64) {
|
|
306
|
+
let mut s = self.0.borrow_mut();
|
|
307
|
+
if s.hub {
|
|
308
|
+
s.wake = (at_ms != 0).then_some(at_ms);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
fn watch(&mut self, req: &[u8], on: bool) -> Vec<u8> {
|
|
313
|
+
if req.len() > 1024 {
|
|
314
|
+
trap(format!("a subscription of {} bytes is over 1024", req.len()));
|
|
315
|
+
}
|
|
316
|
+
let Some(m) = parse_request(req) else {
|
|
317
|
+
return reply(Status::BadRequest, b"malformed subscription");
|
|
318
|
+
};
|
|
319
|
+
let mut s = self.0.borrow_mut();
|
|
320
|
+
if !s.hub {
|
|
321
|
+
return reply(Status::Denied, b"only hubs subscribe to topics");
|
|
322
|
+
}
|
|
323
|
+
if !s.spend(0) {
|
|
324
|
+
return reply(Status::Busy, SENT_ENOUGH.as_bytes());
|
|
325
|
+
}
|
|
326
|
+
if s.is_mod() {
|
|
327
|
+
return s.refuse_mod_call(&m.node_type);
|
|
328
|
+
}
|
|
329
|
+
let entry = (m.node_type, m.key, m.method);
|
|
330
|
+
if on {
|
|
331
|
+
s.watching.insert(entry);
|
|
332
|
+
} else {
|
|
333
|
+
s.watching.remove(&entry);
|
|
334
|
+
}
|
|
335
|
+
reply(Status::Ok, &[])
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
fn persist_now(&mut self) {
|
|
339
|
+
let mut s = self.0.borrow_mut();
|
|
340
|
+
if s.hub {
|
|
341
|
+
s.persist_asked += 1;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
fn emit(&mut self, inject: &[u8]) -> u32 {
|
|
346
|
+
if inject.len() > limits::MAX_EMIT_BYTES {
|
|
347
|
+
return Status::BadRequest.as_u8().into();
|
|
348
|
+
}
|
|
349
|
+
let mut s = self.0.borrow_mut();
|
|
350
|
+
if !s.spend(inject.len()) {
|
|
351
|
+
return Status::Busy.as_u8().into();
|
|
352
|
+
}
|
|
353
|
+
if s.is_mod() {
|
|
354
|
+
s.dropped += 1;
|
|
355
|
+
} else {
|
|
356
|
+
s.emitted.push(inject.to_vec());
|
|
357
|
+
}
|
|
358
|
+
0
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
fn test_ctx(kind: Kind, node_type: &str, key: &str) -> Ctx {
|
|
363
|
+
Ctx { app: TEST_APP, instance: 1, epoch: 1, version: 1, kind, node_type: node_type.to_owned(), key: key.to_owned() }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
fn app_error(message: String) -> CallError {
|
|
367
|
+
CallError { status: Status::AppError, message }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
fn bad_request(message: impl Into<String>) -> CallError {
|
|
371
|
+
CallError { status: Status::BadRequest, message: message.into() }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
fn denied(message: &str) -> CallError {
|
|
375
|
+
CallError { status: Status::Denied, message: message.into() }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/// A start or persist error, cut as the sandbox cuts it.
|
|
379
|
+
fn guest_text(msg: String) -> String {
|
|
380
|
+
let cut = msg.as_bytes();
|
|
381
|
+
String::from_utf8_lossy(&cut[..cut.len().min(limits::MAX_LOG_BYTES)]).into_owned()
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/// The platform as a test's node sees it: what the node did, what answers it, and the clock.
|
|
385
|
+
/// [`TestHub`] and [`TestSpoke`] dereference to theirs.
|
|
386
|
+
pub struct FakePlatform {
|
|
387
|
+
ctx: Ctx,
|
|
388
|
+
state: Rc<RefCell<State>>,
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
impl FakePlatform {
|
|
392
|
+
fn install(ctx: Ctx, grid: Option<FakeGrid>) -> Self {
|
|
393
|
+
assert!(
|
|
394
|
+
native::with(|_| ()).is_none(),
|
|
395
|
+
"one node runs on a thread at a time: drop the last TestHub or TestSpoke before starting another"
|
|
396
|
+
);
|
|
397
|
+
let state = State {
|
|
398
|
+
node_type: ctx.node_type.clone(),
|
|
399
|
+
hub: ctx.kind == Kind::Hub,
|
|
400
|
+
now: START_MS,
|
|
401
|
+
wake: None,
|
|
402
|
+
logs: Vec::new(),
|
|
403
|
+
published: Vec::new(),
|
|
404
|
+
calls: Vec::new(),
|
|
405
|
+
sent: Vec::new(),
|
|
406
|
+
emitted: Vec::new(),
|
|
407
|
+
dropped: 0,
|
|
408
|
+
node_requests: Vec::new(),
|
|
409
|
+
watching: BTreeSet::new(),
|
|
410
|
+
persist_asked: 0,
|
|
411
|
+
logs_left: 0,
|
|
412
|
+
out_msgs_left: 0,
|
|
413
|
+
out_bytes_left: 0,
|
|
414
|
+
call_answers: Vec::new(),
|
|
415
|
+
node_answers: Vec::new(),
|
|
416
|
+
grid,
|
|
417
|
+
};
|
|
418
|
+
let state = Rc::new(RefCell::new(state));
|
|
419
|
+
native::install(Some(Box::new(Shared(state.clone()))));
|
|
420
|
+
Self { ctx, state }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
fn init<N: Node>(&self, seed: &[u8], snapshot: Option<(&[u8], u64)>) -> Result<N, CallError> {
|
|
424
|
+
self.state.borrow_mut().begin();
|
|
425
|
+
N::init(&self.ctx, seed, snapshot).map_err(|Error(m)| app_error(guest_text(m)))
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
fn admit(&self, caller: Caller, method: &str) -> Result<(), CallError> {
|
|
429
|
+
if method.starts_with('$') {
|
|
430
|
+
return Err(denied("methods starting with `$` are the platform's"));
|
|
431
|
+
}
|
|
432
|
+
match caller {
|
|
433
|
+
Caller::Platform => {
|
|
434
|
+
Err(bad_request("the platform calls a node only with its events: advance, topic, presence, session, world"))
|
|
435
|
+
}
|
|
436
|
+
Caller::Instance(_) if self.state.borrow().is_mod() => Err(denied("only players call mods")),
|
|
437
|
+
_ => Ok(()),
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
fn handle<N: Node>(&self, node: &mut N, caller: Caller, method: &str, payload: &[u8]) -> Result<Vec<u8>, CallError> {
|
|
442
|
+
self.state.borrow_mut().begin();
|
|
443
|
+
match node.handle(&self.ctx, Call { caller, method, payload }) {
|
|
444
|
+
Ok(reply) if reply.len() > limits::MAX_PAYLOAD => Err(CallError {
|
|
445
|
+
status: Status::Internal,
|
|
446
|
+
message: format!("the handler's reply of {} bytes exceeds {}", reply.len(), limits::MAX_PAYLOAD),
|
|
447
|
+
}),
|
|
448
|
+
Ok(reply) => Ok(reply),
|
|
449
|
+
Err(Error(message)) => Err(app_error(message)),
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
fn persist<N: Node>(&self, node: &mut N) -> Result<Vec<u8>, CallError> {
|
|
454
|
+
self.state.borrow_mut().begin();
|
|
455
|
+
match node.persist(&self.ctx) {
|
|
456
|
+
Ok(bytes) if bytes.len() > limits::MAX_SNAPSHOT_BYTES => {
|
|
457
|
+
Err(app_error(format!("a snapshot of {} bytes exceeds {}", bytes.len(), limits::MAX_SNAPSHOT_BYTES)))
|
|
458
|
+
}
|
|
459
|
+
Ok(bytes) => Ok(bytes),
|
|
460
|
+
Err(Error(m)) => Err(app_error(guest_text(m))),
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/// The node's identity: app [`TEST_APP`], instance 1, epoch 1, code version 1 (or the
|
|
465
|
+
/// version an upgrade moved it to).
|
|
466
|
+
pub fn ctx(&self) -> &Ctx {
|
|
467
|
+
&self.ctx
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/// The test's clock, in Unix milliseconds. It starts at [`START_MS`] and moves only when the
|
|
471
|
+
/// test moves it.
|
|
472
|
+
pub fn now_ms(&self) -> u64 {
|
|
473
|
+
self.state.borrow().now
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
pub fn logs(&self) -> Vec<LogLine> {
|
|
477
|
+
self.state.borrow().logs.clone()
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/// What the node published, by topic, in order.
|
|
481
|
+
pub fn published(&self) -> Vec<(String, Vec<u8>)> {
|
|
482
|
+
self.state.borrow().published.clone()
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/// The calls the node made (and waited on), in order.
|
|
486
|
+
pub fn calls(&self) -> Vec<Message> {
|
|
487
|
+
self.state.borrow().calls.clone()
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/// The one-way messages the node sent, in order.
|
|
491
|
+
pub fn sent(&self) -> Vec<Message> {
|
|
492
|
+
self.state.borrow().sent.clone()
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/// The realtime injects the node emitted, as [`crate::emit`] built them.
|
|
496
|
+
pub fn emitted(&self) -> Vec<Vec<u8>> {
|
|
497
|
+
self.state.borrow().emitted.clone()
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/// Sends and emits the platform dropped: a mod's.
|
|
501
|
+
pub fn dropped(&self) -> u32 {
|
|
502
|
+
self.state.borrow().dropped
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/// The node API requests the node made: each operation and its JSON body.
|
|
506
|
+
pub fn node_requests(&self) -> Vec<(String, Value)> {
|
|
507
|
+
self.state.borrow().node_requests.clone()
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/// The topics the hub watches: `(node type, key, topic)`.
|
|
511
|
+
pub fn watching(&self) -> Vec<(String, String, String)> {
|
|
512
|
+
self.state.borrow().watching.iter().cloned().collect()
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/// How many times the hub asked for a snapshot with [`Ctx::persist_now`].
|
|
516
|
+
pub fn persist_asked(&self) -> u32 {
|
|
517
|
+
self.state.borrow().persist_asked
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/// When the hub next wants its `$timer` call, if it has a timer.
|
|
521
|
+
pub fn next_wake(&self) -> Option<u64> {
|
|
522
|
+
self.state.borrow().wake
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/// Forgets what the node did so far: logs, publishes, calls, sends, emits and node API
|
|
526
|
+
/// requests.
|
|
527
|
+
pub fn clear(&self) {
|
|
528
|
+
let mut s = self.state.borrow_mut();
|
|
529
|
+
s.logs.clear();
|
|
530
|
+
s.published.clear();
|
|
531
|
+
s.calls.clear();
|
|
532
|
+
s.sent.clear();
|
|
533
|
+
s.emitted.clear();
|
|
534
|
+
s.dropped = 0;
|
|
535
|
+
s.node_requests.clear();
|
|
536
|
+
s.persist_asked = 0;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/// Answers the node's calls to `node_type` (`"*"` for any type) with `answer`; the latest
|
|
540
|
+
/// answer for a type wins. A call nothing answers gets `NotFound`.
|
|
541
|
+
pub fn answer_calls(&self, node_type: &str, answer: impl FnMut(&Message) -> Result<Vec<u8>, CallError> + 'static) {
|
|
542
|
+
self.state.borrow_mut().call_answers.push((node_type.to_owned(), Box::new(answer)));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/// Answers node API operation `op` (`"*"` for any) with `answer`, given the request's JSON
|
|
546
|
+
/// body; the latest answer for an operation wins. An operation nothing answers gets
|
|
547
|
+
/// `NotFound`. A mod's operations are its grid's to answer instead.
|
|
548
|
+
pub fn answer_node(&self, op: &str, answer: impl FnMut(&Value) -> Result<Value, CallError> + 'static) {
|
|
549
|
+
self.state.borrow_mut().node_answers.push((op.to_owned(), Box::new(answer)));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/// A mod's grid, to look at.
|
|
553
|
+
pub fn grid(&self) -> Ref<'_, FakeGrid> {
|
|
554
|
+
Ref::map(self.state.borrow(), |s| s.grid.as_ref().expect("a mod's platform has its grid"))
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/// A mod's grid, to change between calls: move actors, hand the grid to another player.
|
|
558
|
+
pub fn grid_mut(&self) -> RefMut<'_, FakeGrid> {
|
|
559
|
+
RefMut::map(self.state.borrow_mut(), |s| s.grid.as_mut().expect("a mod's platform has its grid"))
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
impl Drop for FakePlatform {
|
|
564
|
+
fn drop(&mut self) {
|
|
565
|
+
native::install(None);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/// A hub (or a mod) under test, with its [`FakePlatform`].
|
|
570
|
+
pub struct TestHub<T: Hub> {
|
|
571
|
+
node: Option<HubNode<T>>,
|
|
572
|
+
platform: FakePlatform,
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
impl<T: Hub> TestHub<T> {
|
|
576
|
+
/// Spawns `(node_type, key)` with an empty seed.
|
|
577
|
+
pub fn spawn(node_type: &str, key: &str) -> Result<Self, CallError> {
|
|
578
|
+
Self::spawn_with(node_type, key, &[])
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/// Spawns `(node_type, key)` with the seed its first caller gave.
|
|
582
|
+
pub fn spawn_with(node_type: &str, key: &str, seed: &[u8]) -> Result<Self, CallError> {
|
|
583
|
+
Self::start(test_ctx(Kind::Hub, node_type, key), None, seed, None)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/// Starts `(node_type, key)` from a snapshot code version `from_version` wrote: what a hub
|
|
587
|
+
/// gets after a deploy, or on another host after a move.
|
|
588
|
+
pub fn load(node_type: &str, key: &str, snapshot: &[u8], from_version: u64) -> Result<Self, CallError> {
|
|
589
|
+
Self::start(test_ctx(Kind::Hub, node_type, key), None, &[], Some((snapshot, from_version)))
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/// Spawns mod `name` on `grid`: the hub `("mod:<name>", "<grid id>")`, under the platform's
|
|
593
|
+
/// rules for mods. Only players call it; its calls and subscriptions are refused, and what
|
|
594
|
+
/// it sends and emits is dropped; its node API operations reach only `grid`.
|
|
595
|
+
pub fn mod_on(name: &str, grid: FakeGrid) -> Result<Self, CallError> {
|
|
596
|
+
let ctx = test_ctx(Kind::Hub, &format!("{}{name}", limits::mods::TYPE_PREFIX), &grid.id.to_string());
|
|
597
|
+
Self::start(ctx, Some(grid), &[], None)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
fn start(ctx: Ctx, grid: Option<FakeGrid>, seed: &[u8], snapshot: Option<(&[u8], u64)>) -> Result<Self, CallError> {
|
|
601
|
+
let platform = FakePlatform::install(ctx, grid);
|
|
602
|
+
let node = platform.init::<HubNode<T>>(seed, snapshot)?;
|
|
603
|
+
Ok(Self { node: Some(node), platform })
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
fn node(&mut self) -> &mut HubNode<T> {
|
|
607
|
+
self.node.as_mut().expect("the hub runs")
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
pub fn hub(&self) -> &T {
|
|
611
|
+
self.node.as_ref().expect("the hub runs").hub()
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
pub fn hub_mut(&mut self) -> &mut T {
|
|
615
|
+
self.node().hub_mut()
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/// Calls `method` as `caller` and returns what the caller receives: the reply, or the
|
|
619
|
+
/// handler's error with status `AppError`.
|
|
620
|
+
pub fn call(&mut self, caller: Caller, method: &str, payload: &[u8]) -> Result<Vec<u8>, CallError> {
|
|
621
|
+
self.platform.admit(caller, method)?;
|
|
622
|
+
let node = self.node.as_mut().expect("the hub runs");
|
|
623
|
+
self.platform.handle(node, caller, method, payload)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/// [`TestHub::call`] with MessagePack on both sides.
|
|
627
|
+
pub fn call_typed<Req: Serialize + ?Sized, Resp: DeserializeOwned>(
|
|
628
|
+
&mut self,
|
|
629
|
+
caller: Caller,
|
|
630
|
+
method: &str,
|
|
631
|
+
req: &Req,
|
|
632
|
+
) -> Result<Resp, CallError> {
|
|
633
|
+
let payload = encode(req).map_err(|Error(m)| bad_request(m))?;
|
|
634
|
+
let reply = self.call(caller, method, &payload)?;
|
|
635
|
+
decode(&reply).map_err(|Error(message)| CallError { status: Status::Internal, message })
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
fn event(&mut self, caller: Caller, method: &str, payload: &[u8]) -> Result<(), CallError> {
|
|
639
|
+
let node = self.node.as_mut().expect("the hub runs");
|
|
640
|
+
self.platform.handle(node, caller, method, payload).map(|_| ())
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/// Moves the clock `ms` on, calling `$timer` each time a timer falls due on the way.
|
|
644
|
+
pub fn advance(&mut self, ms: u64) -> Result<(), CallError> {
|
|
645
|
+
let until = self.now_ms().saturating_add(ms);
|
|
646
|
+
self.advance_to(until)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/// Moves the clock to `at_ms` (Unix milliseconds), calling `$timer` each time a timer falls
|
|
650
|
+
/// due on the way. A time already past changes nothing.
|
|
651
|
+
pub fn advance_to(&mut self, at_ms: u64) -> Result<(), CallError> {
|
|
652
|
+
let mut calls = 0;
|
|
653
|
+
loop {
|
|
654
|
+
let due = self.platform.state.borrow().wake.filter(|&w| w <= at_ms);
|
|
655
|
+
let Some(due) = due else { break };
|
|
656
|
+
calls += 1;
|
|
657
|
+
assert!(calls <= MAX_TIMER_CALLS, "timers fell due {MAX_TIMER_CALLS} times in one advance: one is due again at once");
|
|
658
|
+
{
|
|
659
|
+
let mut s = self.platform.state.borrow_mut();
|
|
660
|
+
s.now = s.now.max(due);
|
|
661
|
+
s.wake = None;
|
|
662
|
+
}
|
|
663
|
+
self.event(Caller::Platform, platform::TIMER, &[])?;
|
|
664
|
+
}
|
|
665
|
+
let mut s = self.platform.state.borrow_mut();
|
|
666
|
+
s.now = s.now.max(at_ms);
|
|
667
|
+
Ok(())
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/// Hands a mod world events as the platform does: only its grid's, at most
|
|
671
|
+
/// [`limits::mods::WORLD_BATCH_MAX`] a call.
|
|
672
|
+
pub fn world(&mut self, events: &[WorldEvent]) -> Result<(), CallError> {
|
|
673
|
+
let Some(grid) = self.platform.state.borrow().grid.as_ref().map(|g| g.id) else {
|
|
674
|
+
return Err(bad_request("world events are for mods"));
|
|
675
|
+
};
|
|
676
|
+
let mine: Vec<WorldEvent> = events.iter().filter(|e| e.grid == grid).cloned().collect();
|
|
677
|
+
for batch in mine.chunks(limits::mods::WORLD_BATCH_MAX) {
|
|
678
|
+
let payload = world_payload(batch).map_err(|Error(m)| bad_request(m))?;
|
|
679
|
+
self.event(Caller::Platform, platform::WORLD, &payload)?;
|
|
680
|
+
}
|
|
681
|
+
Ok(())
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/// A player's connection subscribing to this hub's first topic, or leaving its last.
|
|
685
|
+
pub fn session(&mut self, player: u64, session: Session) -> Result<(), CallError> {
|
|
686
|
+
let payload = [match session {
|
|
687
|
+
Session::Joined => platform::SESSION_JOINED,
|
|
688
|
+
Session::Left => platform::SESSION_LEFT,
|
|
689
|
+
}];
|
|
690
|
+
self.event(Caller::Player(player), platform::SESSION, &payload)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/// Players entering or leaving the app, as the root hub hears it.
|
|
694
|
+
pub fn presence(&mut self, presence: &Presence) -> Result<(), CallError> {
|
|
695
|
+
let payload = encode(presence).map_err(|Error(m)| bad_request(m))?;
|
|
696
|
+
self.event(Caller::Platform, platform::PRESENCE, &payload)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/// `node_type`/`key` publishing `payload` on `topic`. A hub hears only the topics it
|
|
700
|
+
/// watches.
|
|
701
|
+
pub fn topic(&mut self, node_type: &str, key: &str, topic: &str, payload: &[u8]) -> Result<(), CallError> {
|
|
702
|
+
let entry = (node_type.to_owned(), key.to_owned(), topic.to_owned());
|
|
703
|
+
if !self.platform.state.borrow().watching.contains(&entry) {
|
|
704
|
+
return Err(bad_request(format!("the hub does not watch `{topic}` on {node_type}/{key}")));
|
|
705
|
+
}
|
|
706
|
+
self.event(Caller::Platform, platform::TOPIC, &crate::request(node_type, key, topic, payload))
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/// The hub's snapshot, as the platform takes it: its own bytes behind its timers and
|
|
710
|
+
/// watches.
|
|
711
|
+
pub fn persist(&mut self) -> Result<Vec<u8>, CallError> {
|
|
712
|
+
let node = self.node.as_mut().expect("the hub runs");
|
|
713
|
+
self.platform.persist(node)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/// Stops the hub and starts it again from its snapshot, as a move to another host does.
|
|
717
|
+
/// Its timers and watches come back with it.
|
|
718
|
+
pub fn restart(&mut self) -> Result<(), CallError> {
|
|
719
|
+
let snapshot = self.persist()?;
|
|
720
|
+
self.stop();
|
|
721
|
+
let version = self.platform.ctx.version;
|
|
722
|
+
self.node = Some(self.platform.init::<HubNode<T>>(&[], Some((&snapshot, version)))?);
|
|
723
|
+
Ok(())
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/// Deploys the hub's next version: its snapshot, taken now, loads into `U` at code version
|
|
727
|
+
/// one higher.
|
|
728
|
+
pub fn upgrade<U: Hub>(mut self) -> Result<TestHub<U>, CallError> {
|
|
729
|
+
let snapshot = self.persist()?;
|
|
730
|
+
self.stop();
|
|
731
|
+
let mut platform = self.platform;
|
|
732
|
+
let from = platform.ctx.version;
|
|
733
|
+
platform.ctx.version = from + 1;
|
|
734
|
+
let node = platform.init::<HubNode<U>>(&[], Some((&snapshot, from)))?;
|
|
735
|
+
Ok(TestHub { node: Some(node), platform })
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
fn stop(&mut self) {
|
|
739
|
+
if let Some(mut node) = self.node.take() {
|
|
740
|
+
self.platform.state.borrow_mut().begin();
|
|
741
|
+
node.evict(&self.platform.ctx);
|
|
742
|
+
}
|
|
743
|
+
let mut s = self.platform.state.borrow_mut();
|
|
744
|
+
s.wake = None;
|
|
745
|
+
s.watching.clear();
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/// The hub's pending timers and when each is next due.
|
|
749
|
+
pub fn timers(&self) -> Vec<(String, u64)> {
|
|
750
|
+
crate::rt::timers()
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
impl<T: Hub> Deref for TestHub<T> {
|
|
755
|
+
type Target = FakePlatform;
|
|
756
|
+
|
|
757
|
+
fn deref(&self) -> &FakePlatform {
|
|
758
|
+
&self.platform
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/// A replica of a spoke under test, with its [`FakePlatform`].
|
|
763
|
+
pub struct TestSpoke<T: Spoke> {
|
|
764
|
+
node: SpokeNode<T>,
|
|
765
|
+
platform: FakePlatform,
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
impl<T: Spoke> TestSpoke<T> {
|
|
769
|
+
/// Starts a replica of `node_type`.
|
|
770
|
+
pub fn start(node_type: &str) -> Result<Self, CallError> {
|
|
771
|
+
let platform = FakePlatform::install(test_ctx(Kind::Spoke, node_type, ""), None);
|
|
772
|
+
let node = platform.init::<SpokeNode<T>>(&[], None)?;
|
|
773
|
+
Ok(Self { node, platform })
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
pub fn spoke(&self) -> &T {
|
|
777
|
+
self.node.spoke()
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
pub fn spoke_mut(&mut self) -> &mut T {
|
|
781
|
+
self.node.spoke_mut()
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/// Calls `method` as `caller` and returns what the caller receives.
|
|
785
|
+
pub fn call(&mut self, caller: Caller, method: &str, payload: &[u8]) -> Result<Vec<u8>, CallError> {
|
|
786
|
+
self.platform.admit(caller, method)?;
|
|
787
|
+
self.platform.handle(&mut self.node, caller, method, payload)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/// [`TestSpoke::call`] with MessagePack on both sides.
|
|
791
|
+
pub fn call_typed<Req: Serialize + ?Sized, Resp: DeserializeOwned>(
|
|
792
|
+
&mut self,
|
|
793
|
+
caller: Caller,
|
|
794
|
+
method: &str,
|
|
795
|
+
req: &Req,
|
|
796
|
+
) -> Result<Resp, CallError> {
|
|
797
|
+
let payload = encode(req).map_err(|Error(m)| bad_request(m))?;
|
|
798
|
+
let reply = self.call(caller, method, &payload)?;
|
|
799
|
+
decode(&reply).map_err(|Error(message)| CallError { status: Status::Internal, message })
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
impl<T: Spoke> Deref for TestSpoke<T> {
|
|
804
|
+
type Target = FakePlatform;
|
|
805
|
+
|
|
806
|
+
fn deref(&self) -> &FakePlatform {
|
|
807
|
+
&self.platform
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/// The payload of the `$world` call the platform makes for `events`: what [`TestHub::world`]
|
|
812
|
+
/// hands a mod.
|
|
813
|
+
pub fn world_payload(events: &[WorldEvent]) -> crate::Result<Vec<u8>> {
|
|
814
|
+
#[derive(Serialize)]
|
|
815
|
+
struct Batch<'a> {
|
|
816
|
+
events: &'a [WorldEvent],
|
|
817
|
+
}
|
|
818
|
+
encode(&Batch { events })
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ---- the node API, as it serves a mod ----
|
|
822
|
+
|
|
823
|
+
/// The node API operations a mod may call.
|
|
824
|
+
const MOD_OPS: [&str; 7] =
|
|
825
|
+
["world.chunk", "world.voxels", "world.actors", "world.actors_radius", "world.set_voxels", "grids.get", "grids.check_permission"];
|
|
826
|
+
|
|
827
|
+
/// Each node API operation and the scope it needs.
|
|
828
|
+
const OP_SCOPES: [(&str, &str); 27] = [
|
|
829
|
+
("players.get", "players.read"),
|
|
830
|
+
("players.features", "players.read"),
|
|
831
|
+
("players.check_permission", "players.read"),
|
|
832
|
+
("players.state", "players.read"),
|
|
833
|
+
("players.avatar_state", "players.read"),
|
|
834
|
+
("players.set_state", "players.write"),
|
|
835
|
+
("world.chunk", "world.read"),
|
|
836
|
+
("world.voxels", "world.read"),
|
|
837
|
+
("world.actors", "world.read"),
|
|
838
|
+
("world.actors_radius", "world.read"),
|
|
839
|
+
("world.set_voxels", "world.write"),
|
|
840
|
+
("world.set_chunk_state", "world.write"),
|
|
841
|
+
("grids.get", "grids.read"),
|
|
842
|
+
("grids.check_permission", "grids.read"),
|
|
843
|
+
("grids.settings", "grids.read"),
|
|
844
|
+
("grids.create", "grids.write"),
|
|
845
|
+
("grids.delete", "grids.write"),
|
|
846
|
+
("grids.assign_group", "grids.write"),
|
|
847
|
+
("grids.unassign_group", "grids.write"),
|
|
848
|
+
("grids.set_open_permissions", "grids.write"),
|
|
849
|
+
("permissions.grant", "permissions.write"),
|
|
850
|
+
("permissions.revoke", "permissions.write"),
|
|
851
|
+
("access.get", "access.read"),
|
|
852
|
+
("access.grant", "access.write"),
|
|
853
|
+
("access.revoke", "access.write"),
|
|
854
|
+
("access.suspend", "access.write"),
|
|
855
|
+
("access.unsuspend", "access.write"),
|
|
856
|
+
];
|
|
857
|
+
|
|
858
|
+
/// Rows the node API returns at most: actors in one chunk, in a radius, voxels in a chunk.
|
|
859
|
+
const ACTOR_ROWS: usize = 200;
|
|
860
|
+
const RADIUS_ROWS: usize = 500;
|
|
861
|
+
const VOXEL_ROWS: usize = 2048;
|
|
862
|
+
const RADIUS_XZ: i64 = 3;
|
|
863
|
+
const RADIUS_Y: i64 = 1;
|
|
864
|
+
|
|
865
|
+
/// Base64 as the game API reads it: padding or none.
|
|
866
|
+
const LENIENT: GeneralPurpose =
|
|
867
|
+
GeneralPurpose::new(&alphabet::STANDARD, GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent));
|
|
868
|
+
|
|
869
|
+
/// The node API's refusal: the game API's error body with its HTTP status, as the host hands
|
|
870
|
+
/// both to the node.
|
|
871
|
+
fn refusal(http: u16, message: &str) -> CallError {
|
|
872
|
+
let (status, error) = match http {
|
|
873
|
+
400 => (Status::AppError, "Bad Request"),
|
|
874
|
+
403 => (Status::Denied, "Forbidden"),
|
|
875
|
+
404 => (Status::NotFound, "Not Found"),
|
|
876
|
+
_ => (Status::Unavailable, "Error"),
|
|
877
|
+
};
|
|
878
|
+
let message = serde_json::to_string(message).expect("a string encodes");
|
|
879
|
+
CallError { status, message: format!(r#"{{"message":{message},"error":"{error}","statusCode":{http}}}"#) }
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
fn int64(value: Option<&Value>, name: &str) -> Result<i64, CallError> {
|
|
883
|
+
let bad = || refusal(400, &format!("{name} must be a 64-bit integer"));
|
|
884
|
+
match value {
|
|
885
|
+
Some(Value::Number(n)) => {
|
|
886
|
+
let f = n.as_f64().ok_or_else(bad)?;
|
|
887
|
+
if f.fract() != 0.0 || f.abs() > 9_007_199_254_740_991.0 {
|
|
888
|
+
return Err(bad());
|
|
889
|
+
}
|
|
890
|
+
Ok(f as i64)
|
|
891
|
+
}
|
|
892
|
+
Some(Value::String(s)) => {
|
|
893
|
+
let digits = s.strip_prefix('-').unwrap_or(s);
|
|
894
|
+
if digits.is_empty() || digits.len() > 19 || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
|
895
|
+
return Err(bad());
|
|
896
|
+
}
|
|
897
|
+
s.parse().map_err(|_| bad())
|
|
898
|
+
}
|
|
899
|
+
_ => Err(bad()),
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
fn id64(value: Option<&Value>, name: &str) -> Result<u64, CallError> {
|
|
904
|
+
let v = int64(value, name)?;
|
|
905
|
+
if v <= 0 {
|
|
906
|
+
return Err(refusal(400, &format!("{name} must be a positive id")));
|
|
907
|
+
}
|
|
908
|
+
Ok(v as u64)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
fn int_in(value: Option<&Value>, name: &str, min: i64, max: i64) -> Result<i64, CallError> {
|
|
912
|
+
let n = value.and_then(Value::as_f64).filter(|f| f.fract() == 0.0 && *f >= min as f64 && *f <= max as f64);
|
|
913
|
+
n.map(|f| f as i64).ok_or_else(|| refusal(400, &format!("{name} must be an integer from {min} to {max}")))
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
fn text(value: Option<&Value>, name: &str) -> Result<String, CallError> {
|
|
917
|
+
match value.and_then(Value::as_str) {
|
|
918
|
+
Some(s) if !s.is_empty() && s.encode_utf16().count() <= 128 => Ok(s.to_owned()),
|
|
919
|
+
_ => Err(refusal(400, &format!("{name} must be a non-empty string of at most 128 characters"))),
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
fn base64_bytes(value: Option<&Value>, name: &str, max: usize) -> Result<Option<Vec<u8>>, CallError> {
|
|
924
|
+
let s = match value {
|
|
925
|
+
None | Some(Value::Null) => return Ok(None),
|
|
926
|
+
Some(Value::String(s)) => s,
|
|
927
|
+
Some(_) => return Err(refusal(400, &format!("{name} must be base64"))),
|
|
928
|
+
};
|
|
929
|
+
let body = s.trim_end_matches('=');
|
|
930
|
+
if s.len() - body.len() > 2 || !body.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/') {
|
|
931
|
+
return Err(refusal(400, &format!("{name} must be base64")));
|
|
932
|
+
}
|
|
933
|
+
let bytes = LENIENT.decode(body).map_err(|_| refusal(400, &format!("{name} must be base64")))?;
|
|
934
|
+
if bytes.len() > max {
|
|
935
|
+
return Err(refusal(400, &format!("{name} is capped at {max} bytes")));
|
|
936
|
+
}
|
|
937
|
+
Ok(Some(bytes))
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
fn chunk_pos(value: Option<&Value>, name: &str) -> Result<ChunkPos, CallError> {
|
|
941
|
+
let v = value.unwrap_or(&Value::Null);
|
|
942
|
+
Ok(ChunkPos::new(
|
|
943
|
+
int64(v.get("x"), &format!("{name}.x"))?,
|
|
944
|
+
int64(v.get("y"), &format!("{name}.y"))?,
|
|
945
|
+
int64(v.get("z"), &format!("{name}.z"))?,
|
|
946
|
+
))
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
fn pos_json(p: ChunkPos) -> Value {
|
|
950
|
+
json!({ "x": p.x.to_string(), "y": p.y.to_string(), "z": p.z.to_string() })
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
fn b64(bytes: &[u8]) -> String {
|
|
954
|
+
B64.encode(bytes)
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/// Unix milliseconds as the game API writes a time: `2026-01-01T00:00:00.000Z`.
|
|
958
|
+
fn rfc3339(ms: u64) -> String {
|
|
959
|
+
let (secs, millis) = (ms / 1000, ms % 1000);
|
|
960
|
+
let (days, rem) = ((secs / 86_400) as i64, secs % 86_400);
|
|
961
|
+
let z = days + 719_468;
|
|
962
|
+
let (era, doe) = (z.div_euclid(146_097), z.rem_euclid(146_097));
|
|
963
|
+
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
|
964
|
+
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
965
|
+
let mp = (5 * doy + 2) / 153;
|
|
966
|
+
let day = doy - (153 * mp + 2) / 5 + 1;
|
|
967
|
+
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
968
|
+
let year = yoe + era * 400 + i64::from(month <= 2);
|
|
969
|
+
format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{millis:03}Z", rem / 3600, rem % 3600 / 60, rem % 60)
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
type Pos = (i64, i64, i64);
|
|
973
|
+
|
|
974
|
+
fn key(p: ChunkPos) -> Pos {
|
|
975
|
+
(p.x, p.y, p.z)
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
#[derive(Debug, Clone, Default)]
|
|
979
|
+
struct FakeChunk {
|
|
980
|
+
/// The chunk as stored, when the app stored it.
|
|
981
|
+
stored: Option<Chunk>,
|
|
982
|
+
/// Voxels written into it, by `(y, x, z)`: the order the node API lists them in.
|
|
983
|
+
edits: BTreeMap<(i16, i16, i16), Voxel>,
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/// A mod's grid as the node API serves it to the mod: the box, its owner, the chunks and
|
|
987
|
+
/// voxels in it, the actors there and its permissions, under the node API's rules for mods.
|
|
988
|
+
/// A mod calls seven operations (`world.chunk`, `world.voxels`, `world.actors`,
|
|
989
|
+
/// `world.actors_radius`, `world.set_voxels`, `grids.get`, `grids.check_permission`), only
|
|
990
|
+
/// inside its grid; writes voxels only where its owner may edit them; and is refused
|
|
991
|
+
/// everything once its owner no longer owns the grid. A refusal reads as the game API's.
|
|
992
|
+
#[derive(Debug, Clone)]
|
|
993
|
+
pub struct FakeGrid {
|
|
994
|
+
id: u64,
|
|
995
|
+
low: ChunkPos,
|
|
996
|
+
high: ChunkPos,
|
|
997
|
+
owner: u64,
|
|
998
|
+
/// Who holds the grid now; `None` once it is gone.
|
|
999
|
+
holder: Option<u64>,
|
|
1000
|
+
open_permissions: BTreeSet<String>,
|
|
1001
|
+
chunks: BTreeMap<Pos, FakeChunk>,
|
|
1002
|
+
actors: Vec<Actor>,
|
|
1003
|
+
grants: BTreeSet<(u64, String)>,
|
|
1004
|
+
/// Chunks where a grid nested in this one does not grant the owner `update_voxel_data`.
|
|
1005
|
+
closed: BTreeSet<Pos>,
|
|
1006
|
+
next_update: u64,
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
impl FakeGrid {
|
|
1010
|
+
/// Grid `id` from chunk `low` to chunk `high` (both included), owned by player `owner`, who
|
|
1011
|
+
/// deployed the mod.
|
|
1012
|
+
pub fn new(id: u64, low: ChunkPos, high: ChunkPos, owner: u64) -> Self {
|
|
1013
|
+
Self {
|
|
1014
|
+
id,
|
|
1015
|
+
low,
|
|
1016
|
+
high,
|
|
1017
|
+
owner,
|
|
1018
|
+
holder: Some(owner),
|
|
1019
|
+
open_permissions: BTreeSet::new(),
|
|
1020
|
+
chunks: BTreeMap::new(),
|
|
1021
|
+
actors: Vec::new(),
|
|
1022
|
+
grants: BTreeSet::new(),
|
|
1023
|
+
closed: BTreeSet::new(),
|
|
1024
|
+
next_update: 1,
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
pub fn id(&self) -> u64 {
|
|
1029
|
+
self.id
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/// The player who deployed the mod.
|
|
1033
|
+
pub fn owner(&self) -> u64 {
|
|
1034
|
+
self.owner
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
pub fn contains(&self, at: ChunkPos) -> bool {
|
|
1038
|
+
self.within(at, 0, 0)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/// A chunk the app stored: its dense grid and its state blob.
|
|
1042
|
+
pub fn with_chunk(mut self, chunk: Chunk) -> Self {
|
|
1043
|
+
let at = key(chunk.at);
|
|
1044
|
+
self.chunks.entry(at).or_default().stored = Some(chunk);
|
|
1045
|
+
self
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/// A voxel written into `chunk` before the test.
|
|
1049
|
+
pub fn with_voxel(mut self, chunk: ChunkPos, voxel: Voxel) -> Self {
|
|
1050
|
+
self.chunks.entry(key(chunk)).or_default().edits.insert((voxel.y, voxel.x, voxel.z), voxel);
|
|
1051
|
+
self
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/// An actor in the world; the node API lists the ones in the chunks asked about.
|
|
1055
|
+
pub fn with_actor(mut self, actor: Actor) -> Self {
|
|
1056
|
+
self.actors.push(actor);
|
|
1057
|
+
self
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/// `player` holds permission key `permission` on the grid.
|
|
1061
|
+
pub fn with_permission(mut self, player: u64, permission: &str) -> Self {
|
|
1062
|
+
self.grants.insert((player, permission.to_owned()));
|
|
1063
|
+
self
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/// The keys every player holds on the grid.
|
|
1067
|
+
pub fn with_open_permissions(mut self, permissions: &[&str]) -> Self {
|
|
1068
|
+
self.open_permissions = permissions.iter().map(|p| (*p).to_owned()).collect();
|
|
1069
|
+
self
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/// A grid nested in this one decides `chunk`, and does not grant the owner
|
|
1073
|
+
/// `update_voxel_data` there: the mod's writes to it are refused.
|
|
1074
|
+
pub fn without_voxel_edits_at(mut self, chunk: ChunkPos) -> Self {
|
|
1075
|
+
self.closed.insert(key(chunk));
|
|
1076
|
+
self
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
pub fn actors(&self) -> &[Actor] {
|
|
1080
|
+
&self.actors
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/// Replaces the actors, as players move.
|
|
1084
|
+
pub fn set_actors(&mut self, actors: Vec<Actor>) {
|
|
1085
|
+
self.actors = actors;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/// Another player owns the grid now: the mod's node API operations are refused.
|
|
1089
|
+
pub fn transfer(&mut self, to: u64) {
|
|
1090
|
+
self.holder = Some(to);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/// The grid is deleted: the mod's node API operations are refused.
|
|
1094
|
+
pub fn delete(&mut self) {
|
|
1095
|
+
self.holder = None;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/// The chunk at `at`, as stored.
|
|
1099
|
+
pub fn chunk(&self, at: ChunkPos) -> Option<Chunk> {
|
|
1100
|
+
self.chunks.get(&key(at)).and_then(|c| c.stored.clone())
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/// The voxels written into the chunk at `at`, in the node API's order.
|
|
1104
|
+
pub fn voxels(&self, at: ChunkPos) -> Vec<Voxel> {
|
|
1105
|
+
self.chunks.get(&key(at)).map(|c| c.edits.values().cloned().collect()).unwrap_or_default()
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/// The voxel written at `voxel` in `chunk`, if one was.
|
|
1109
|
+
pub fn voxel(&self, chunk: ChunkPos, voxel: (i16, i16, i16)) -> Option<Voxel> {
|
|
1110
|
+
self.chunks.get(&key(chunk)).and_then(|c| c.edits.get(&(voxel.1, voxel.0, voxel.2)).cloned())
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
fn within(&self, at: ChunkPos, rxz: i64, ry: i64) -> bool {
|
|
1114
|
+
let (x, y, z) = (i128::from(at.x), i128::from(at.y), i128::from(at.z));
|
|
1115
|
+
let (rxz, ry) = (i128::from(rxz), i128::from(ry));
|
|
1116
|
+
x - rxz >= i128::from(self.low.x)
|
|
1117
|
+
&& x + rxz <= i128::from(self.high.x)
|
|
1118
|
+
&& y - ry >= i128::from(self.low.y)
|
|
1119
|
+
&& y + ry <= i128::from(self.high.y)
|
|
1120
|
+
&& z - rxz >= i128::from(self.low.z)
|
|
1121
|
+
&& z + rxz <= i128::from(self.high.z)
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
fn inside(&self, at: ChunkPos, rxz: i64, ry: i64) -> Result<(), CallError> {
|
|
1125
|
+
if self.within(at, rxz, ry) { Ok(()) } else { Err(refusal(403, "a mod reaches only inside its grid's bounds")) }
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
fn own_grid(&self, body: &Value) -> Result<(), CallError> {
|
|
1129
|
+
match body.get("grid") {
|
|
1130
|
+
Some(v) if id64(Some(v), "grid")? == self.id => Ok(()),
|
|
1131
|
+
_ => Err(refusal(403, "a mod reads only its own grid")),
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
fn chunk_json(&self, at: ChunkPos) -> Value {
|
|
1136
|
+
match self.chunk(at) {
|
|
1137
|
+
None => Value::Null,
|
|
1138
|
+
Some(c) => json!({
|
|
1139
|
+
"chunkId": c.id.to_string(),
|
|
1140
|
+
"x": at.x.to_string(),
|
|
1141
|
+
"y": at.y.to_string(),
|
|
1142
|
+
"z": at.z.to_string(),
|
|
1143
|
+
"stateBase64": c.voxels.as_deref().map(b64),
|
|
1144
|
+
"chunkStateBase64": c.chunk_state.as_deref().map(b64),
|
|
1145
|
+
"updatedAt": Value::Null,
|
|
1146
|
+
}),
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
fn voxels_json(&self, at: ChunkPos) -> Value {
|
|
1151
|
+
let list: Vec<Value> = self
|
|
1152
|
+
.voxels(at)
|
|
1153
|
+
.into_iter()
|
|
1154
|
+
.take(VOXEL_ROWS)
|
|
1155
|
+
.map(|v| {
|
|
1156
|
+
json!({
|
|
1157
|
+
"voxelX": v.x,
|
|
1158
|
+
"voxelY": v.y,
|
|
1159
|
+
"voxelZ": v.z,
|
|
1160
|
+
"voxelType": v.voxel_type,
|
|
1161
|
+
"stateBase64": v.state.as_deref().map(b64),
|
|
1162
|
+
"updatedAt": v.updated_at,
|
|
1163
|
+
"createdBy": v.created_by,
|
|
1164
|
+
})
|
|
1165
|
+
})
|
|
1166
|
+
.collect();
|
|
1167
|
+
Value::Array(list)
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
fn actors_json(&self, low: ChunkPos, high: ChunkPos, rows: usize) -> Value {
|
|
1171
|
+
let inside = |c: ChunkPos| (low.x..=high.x).contains(&c.x) && (low.y..=high.y).contains(&c.y) && (low.z..=high.z).contains(&c.z);
|
|
1172
|
+
let list: Vec<Value> = self
|
|
1173
|
+
.actors
|
|
1174
|
+
.iter()
|
|
1175
|
+
.filter(|a| inside(a.chunk))
|
|
1176
|
+
.take(rows)
|
|
1177
|
+
.map(|a| {
|
|
1178
|
+
json!({
|
|
1179
|
+
"uuid": a.uuid,
|
|
1180
|
+
"uuidHex": a.uuid.bytes().map(|b| format!("{b:02x}")).collect::<String>(),
|
|
1181
|
+
"userId": a.player.map(|p| p.to_string()),
|
|
1182
|
+
"stateBase64": a.state.as_deref().map(b64),
|
|
1183
|
+
"chunkX": a.chunk.x.to_string(),
|
|
1184
|
+
"chunkY": a.chunk.y.to_string(),
|
|
1185
|
+
"chunkZ": a.chunk.z.to_string(),
|
|
1186
|
+
"updatedAt": Value::Null,
|
|
1187
|
+
})
|
|
1188
|
+
})
|
|
1189
|
+
.collect();
|
|
1190
|
+
Value::Array(list)
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
fn grid_json(&self) -> Value {
|
|
1194
|
+
json!({
|
|
1195
|
+
"gridId": self.id.to_string(),
|
|
1196
|
+
"low": pos_json(self.low),
|
|
1197
|
+
"high": pos_json(self.high),
|
|
1198
|
+
"createdAt": Value::Null,
|
|
1199
|
+
"owner": self.holder.map(|h| json!({ "kind": "user", "ref": h.to_string() })),
|
|
1200
|
+
"openPermissions": self.open_permissions.iter().collect::<Vec<_>>(),
|
|
1201
|
+
})
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/// Answers node API operation `op` with `body` for the grid's mod at Unix time `now`.
|
|
1205
|
+
fn answer(&mut self, op: &str, body: &Value, now: u64) -> Result<Value, CallError> {
|
|
1206
|
+
let Some(&(_, scope)) = OP_SCOPES.iter().find(|(o, _)| *o == op) else {
|
|
1207
|
+
return Err(refusal(404, &format!("unknown node API operation {op}")));
|
|
1208
|
+
};
|
|
1209
|
+
if !limits::mods::SCOPES.contains(&scope) {
|
|
1210
|
+
return Err(refusal(403, &format!("the capability lacks scope {scope}")));
|
|
1211
|
+
}
|
|
1212
|
+
let writes = if op == "world.set_voxels" { voxel_writes(body)? } else { Vec::new() };
|
|
1213
|
+
if op == "world.set_chunk_state" {
|
|
1214
|
+
base64_bytes(body.get("stateBase64"), "stateBase64", crate::node::MAX_CHUNK_STATE)?;
|
|
1215
|
+
}
|
|
1216
|
+
if !MOD_OPS.contains(&op) {
|
|
1217
|
+
return Err(refusal(403, &format!("a mod may not call {op}")));
|
|
1218
|
+
}
|
|
1219
|
+
let Some(holder) = self.holder else {
|
|
1220
|
+
return Err(refusal(404, "the mod's grid is gone"));
|
|
1221
|
+
};
|
|
1222
|
+
if holder != self.owner {
|
|
1223
|
+
return Err(refusal(403, "the mod's owner no longer owns its grid"));
|
|
1224
|
+
}
|
|
1225
|
+
match op {
|
|
1226
|
+
"world.chunk" | "world.voxels" | "world.actors" => {
|
|
1227
|
+
let at = chunk_pos(Some(body), "chunk")?;
|
|
1228
|
+
self.inside(at, 0, 0)?;
|
|
1229
|
+
Ok(match op {
|
|
1230
|
+
"world.chunk" => json!({ "chunk": self.chunk_json(at) }),
|
|
1231
|
+
"world.voxels" => json!({ "voxels": self.voxels_json(at) }),
|
|
1232
|
+
_ => json!({ "actors": self.actors_json(at, at, ACTOR_ROWS) }),
|
|
1233
|
+
})
|
|
1234
|
+
}
|
|
1235
|
+
"world.actors_radius" => {
|
|
1236
|
+
let or_zero = |k: &str| body.get(k).filter(|v| !v.is_null()).cloned().unwrap_or(json!(0));
|
|
1237
|
+
let rxz = int_in(Some(&or_zero("radiusXz")), "radiusXz", 0, RADIUS_XZ)?;
|
|
1238
|
+
let ry = int_in(Some(&or_zero("radiusY")), "radiusY", 0, RADIUS_Y)?;
|
|
1239
|
+
let at = chunk_pos(Some(body), "chunk")?;
|
|
1240
|
+
self.inside(at, rxz, ry)?;
|
|
1241
|
+
let low = ChunkPos::new(at.x - rxz, at.y - ry, at.z - rxz);
|
|
1242
|
+
let high = ChunkPos::new(at.x + rxz, at.y + ry, at.z + rxz);
|
|
1243
|
+
Ok(json!({ "actors": self.actors_json(low, high, RADIUS_ROWS) }))
|
|
1244
|
+
}
|
|
1245
|
+
"world.set_voxels" => {
|
|
1246
|
+
if !writes.iter().all(|w| self.contains(w.chunk)) {
|
|
1247
|
+
return Err(refusal(403, "a mod reaches only inside its grid's bounds"));
|
|
1248
|
+
}
|
|
1249
|
+
let mut seen = BTreeSet::new();
|
|
1250
|
+
for w in &writes {
|
|
1251
|
+
let at = key(w.chunk);
|
|
1252
|
+
if seen.insert(at) && self.closed.contains(&at) {
|
|
1253
|
+
return Err(refusal(
|
|
1254
|
+
403,
|
|
1255
|
+
&format!(
|
|
1256
|
+
"the mod's owner may not edit voxels at chunk ({}, {}, {}): the most specific grid there does not grant them 'update_voxel_data'",
|
|
1257
|
+
at.0, at.1, at.2
|
|
1258
|
+
),
|
|
1259
|
+
));
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
let mut ids = Vec::with_capacity(writes.len());
|
|
1263
|
+
for w in writes {
|
|
1264
|
+
let voxel = Voxel {
|
|
1265
|
+
x: w.voxel.0,
|
|
1266
|
+
y: w.voxel.1,
|
|
1267
|
+
z: w.voxel.2,
|
|
1268
|
+
voxel_type: w.voxel_type,
|
|
1269
|
+
state: w.state,
|
|
1270
|
+
created_by: Some("0".to_owned()),
|
|
1271
|
+
updated_at: Some(rfc3339(now)),
|
|
1272
|
+
};
|
|
1273
|
+
self.chunks.entry(key(w.chunk)).or_default().edits.insert((voxel.y, voxel.x, voxel.z), voxel);
|
|
1274
|
+
ids.push(self.next_update.to_string());
|
|
1275
|
+
self.next_update += 1;
|
|
1276
|
+
}
|
|
1277
|
+
Ok(json!({ "voxelUpdateIds": ids }))
|
|
1278
|
+
}
|
|
1279
|
+
"grids.get" => {
|
|
1280
|
+
self.own_grid(body)?;
|
|
1281
|
+
Ok(json!({ "grid": self.grid_json() }))
|
|
1282
|
+
}
|
|
1283
|
+
_ => {
|
|
1284
|
+
self.own_grid(body)?;
|
|
1285
|
+
let player = id64(body.get("player"), "player")?;
|
|
1286
|
+
let permission = text(body.get("permission"), "permission")?;
|
|
1287
|
+
Ok(json!({ "allowed": self.grants.contains(&(player, permission)) }))
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
struct Write {
|
|
1294
|
+
chunk: ChunkPos,
|
|
1295
|
+
voxel: (i16, i16, i16),
|
|
1296
|
+
voxel_type: i16,
|
|
1297
|
+
state: Option<Vec<u8>>,
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
fn voxel_writes(body: &Value) -> Result<Vec<Write>, CallError> {
|
|
1301
|
+
let max = crate::node::MAX_VOXEL_WRITES;
|
|
1302
|
+
let list = body
|
|
1303
|
+
.get("writes")
|
|
1304
|
+
.and_then(Value::as_array)
|
|
1305
|
+
.filter(|l| (1..=max).contains(&l.len()))
|
|
1306
|
+
.ok_or_else(|| refusal(400, &format!("writes must list 1 to {max} voxel writes")))?;
|
|
1307
|
+
let axis = |v: &Value, i: usize, k: &str| int_in(v.get(k), &format!("writes[{i}].voxel.{k}"), i16::MIN.into(), i16::MAX.into());
|
|
1308
|
+
list.iter()
|
|
1309
|
+
.enumerate()
|
|
1310
|
+
.map(|(i, w)| {
|
|
1311
|
+
let chunk = chunk_pos(w.get("chunk"), &format!("writes[{i}].chunk"))?;
|
|
1312
|
+
let v = w.get("voxel").unwrap_or(&Value::Null);
|
|
1313
|
+
let voxel = (axis(v, i, "x")? as i16, axis(v, i, "y")? as i16, axis(v, i, "z")? as i16);
|
|
1314
|
+
let voxel_type = int_in(w.get("voxelType"), &format!("writes[{i}].voxelType"), i16::MIN.into(), i16::MAX.into())? as i16;
|
|
1315
|
+
let state = base64_bytes(w.get("stateBase64"), &format!("writes[{i}].stateBase64"), crate::node::MAX_VOXEL_STATE)?;
|
|
1316
|
+
Ok(Write { chunk, voxel, voxel_type, state })
|
|
1317
|
+
})
|
|
1318
|
+
.collect()
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
#[cfg(test)]
|
|
1322
|
+
mod tests {
|
|
1323
|
+
use super::*;
|
|
1324
|
+
use crate::prelude::*;
|
|
1325
|
+
|
|
1326
|
+
#[derive(Default, Serialize, Deserialize)]
|
|
1327
|
+
struct Ticker {
|
|
1328
|
+
ticks: u32,
|
|
1329
|
+
heard: Vec<String>,
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
impl Hub for Ticker {
|
|
1333
|
+
fn spawn(ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
|
|
1334
|
+
ctx.timer_every("tick", 100)?;
|
|
1335
|
+
Ok(Self::default())
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
|
|
1339
|
+
decode(snapshot)
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
|
|
1343
|
+
encode(self)
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
1347
|
+
match call.method {
|
|
1348
|
+
"watch" => {
|
|
1349
|
+
ctx.subscribe("arena", "m1", "hp")?;
|
|
1350
|
+
Ok(Vec::new())
|
|
1351
|
+
}
|
|
1352
|
+
"ask" => Ok(ctx.call("bank", "a", "balance", call.payload)?),
|
|
1353
|
+
"flood" => {
|
|
1354
|
+
for _ in 0..40 {
|
|
1355
|
+
ctx.log(Level::Info, "line");
|
|
1356
|
+
}
|
|
1357
|
+
let mut refused = 0u32;
|
|
1358
|
+
for _ in 0..=limits::OUT_MSGS_PER_CALL {
|
|
1359
|
+
if ctx.send("bank", "a", "noop", &[]).is_err() {
|
|
1360
|
+
refused += 1;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
encode(&refused)
|
|
1364
|
+
}
|
|
1365
|
+
"big" => Ok(vec![0; limits::MAX_PAYLOAD + 1]),
|
|
1366
|
+
other => Err(Error::unknown_method(other)),
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
fn on_timer(&mut self, ctx: &Ctx, _timer: &str) -> Result<()> {
|
|
1371
|
+
self.ticks += 1;
|
|
1372
|
+
ctx.publish("ticks", &encode(&self.ticks)?);
|
|
1373
|
+
Ok(())
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
fn on_topic(&mut self, _ctx: &Ctx, msg: TopicMsg<'_>) -> Result<()> {
|
|
1377
|
+
self.heard.push(format!("{}/{} {}", msg.node_type, msg.key, msg.topic));
|
|
1378
|
+
Ok(())
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
#[test]
|
|
1383
|
+
fn timers_fall_due_as_the_clock_moves_and_survive_a_restart() {
|
|
1384
|
+
let mut hub = TestHub::<Ticker>::spawn("ticker", "a").unwrap();
|
|
1385
|
+
assert_eq!(hub.next_wake(), Some(START_MS + 100));
|
|
1386
|
+
hub.advance(350).unwrap();
|
|
1387
|
+
assert_eq!((hub.hub().ticks, hub.published().len()), (3, 3));
|
|
1388
|
+
hub.restart().unwrap();
|
|
1389
|
+
assert_eq!(hub.timers(), vec![("tick".to_owned(), START_MS + 400)]);
|
|
1390
|
+
hub.advance(50).unwrap();
|
|
1391
|
+
assert_eq!(hub.hub().ticks, 4);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
#[test]
|
|
1395
|
+
fn topics_reach_a_hub_only_while_it_watches_them() {
|
|
1396
|
+
let mut hub = TestHub::<Ticker>::spawn("ticker", "a").unwrap();
|
|
1397
|
+
assert_eq!(hub.topic("arena", "m1", "hp", b"\x09").unwrap_err().status, Status::BadRequest);
|
|
1398
|
+
hub.call(Caller::Player(1), "watch", &[]).unwrap();
|
|
1399
|
+
hub.topic("arena", "m1", "hp", b"\x09").unwrap();
|
|
1400
|
+
hub.restart().unwrap();
|
|
1401
|
+
assert_eq!(hub.watching(), vec![("arena".to_owned(), "m1".to_owned(), "hp".to_owned())], "renewed on start");
|
|
1402
|
+
assert_eq!(hub.hub().heard, vec!["arena/m1 hp".to_owned()]);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
#[test]
|
|
1406
|
+
fn calls_to_other_types_get_what_the_test_scripts() {
|
|
1407
|
+
let mut hub = TestHub::<Ticker>::spawn("ticker", "a").unwrap();
|
|
1408
|
+
let e = hub.call(Caller::Player(1), "ask", b"q").unwrap_err();
|
|
1409
|
+
assert!(e.message.starts_with("NotFound: the test answers no calls to `bank`"), "{e}");
|
|
1410
|
+
hub.answer_calls("bank", |m| Ok([m.payload.as_slice(), b"!"].concat()));
|
|
1411
|
+
assert_eq!(hub.call(Caller::Player(1), "ask", b"q").unwrap(), b"q!");
|
|
1412
|
+
assert_eq!(hub.calls().last().map(|m| m.method.clone()), Some("balance".to_owned()));
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
#[test]
|
|
1416
|
+
fn a_call_is_held_to_the_sandbox_bounds() {
|
|
1417
|
+
let mut hub = TestHub::<Ticker>::spawn("ticker", "a").unwrap();
|
|
1418
|
+
let refused: u32 = hub.call_typed(Caller::Player(1), "flood", &()).unwrap();
|
|
1419
|
+
assert_eq!((refused, hub.logs().len()), (1, limits::LOGS_PER_CALL as usize));
|
|
1420
|
+
assert_eq!(hub.sent().len(), limits::OUT_MSGS_PER_CALL as usize);
|
|
1421
|
+
let e = hub.call(Caller::Player(1), "big", &[]).unwrap_err();
|
|
1422
|
+
assert!(e.status == Status::Internal && e.message.contains("exceeds"), "{e}");
|
|
1423
|
+
assert_eq!(hub.call(Caller::Player(1), "$timer", &[]).unwrap_err().status, Status::Denied);
|
|
1424
|
+
assert_eq!(hub.call(Caller::Player(1), "nope", &[]).unwrap_err().status, Status::AppError);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
#[test]
|
|
1428
|
+
fn one_node_runs_on_a_thread_at_a_time() {
|
|
1429
|
+
let hub = TestHub::<Ticker>::spawn("ticker", "a").unwrap();
|
|
1430
|
+
let second = std::panic::catch_unwind(|| TestHub::<Ticker>::spawn("ticker", "b").map(|_| ()));
|
|
1431
|
+
assert!(second.is_err());
|
|
1432
|
+
drop(hub);
|
|
1433
|
+
TestHub::<Ticker>::spawn("ticker", "b").unwrap();
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
#[test]
|
|
1437
|
+
fn writes_times_as_the_game_api_does() {
|
|
1438
|
+
assert_eq!(rfc3339(START_MS), "2026-01-01T00:00:00.000Z");
|
|
1439
|
+
assert_eq!(rfc3339(951_782_400_123), "2000-02-29T00:00:00.123Z");
|
|
1440
|
+
assert_eq!(rfc3339(0), "1970-01-01T00:00:00.000Z");
|
|
1441
|
+
}
|
|
1442
|
+
}
|