@crowdedkingdoms/ckx-kit 0.0.0-stage → 0.1.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.
Files changed (55) hide show
  1. package/LICENSE-APACHE +176 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +76 -2
  4. package/checks/compute-toolchain/host-catalog.json +68 -0
  5. package/crates/ckx-sdk/Cargo.toml +17 -0
  6. package/crates/ckx-sdk/LICENSE-APACHE +176 -0
  7. package/crates/ckx-sdk/LICENSE-MIT +21 -0
  8. package/crates/ckx-sdk/README.md +47 -0
  9. package/crates/ckx-sdk/src/abi.rs +115 -0
  10. package/crates/ckx-sdk/src/cron.rs +717 -0
  11. package/crates/ckx-sdk/src/emit.rs +140 -0
  12. package/crates/ckx-sdk/src/lib.rs +588 -0
  13. package/crates/ckx-sdk/src/model.rs +482 -0
  14. package/crates/ckx-sdk/src/node.rs +942 -0
  15. package/crates/ckx-sdk/src/rt.rs +519 -0
  16. package/crates/crowdy-client-sdk/Cargo.toml +15 -0
  17. package/crates/crowdy-client-sdk/LICENSE-APACHE +176 -0
  18. package/crates/crowdy-client-sdk/LICENSE-MIT +21 -0
  19. package/crates/crowdy-client-sdk/README.md +25 -0
  20. package/crates/crowdy-client-sdk/src/lib.rs +698 -0
  21. package/dist/checks/src/build/client-capabilities.js +123 -0
  22. package/dist/checks/src/build/exec-source.js +343 -0
  23. package/dist/checks/src/build/rust-source-guard.js +165 -0
  24. package/dist/checks/src/build/wasm-inspect.js +249 -0
  25. package/dist/cli/src/build.js +99 -0
  26. package/dist/cli/src/crate.js +64 -0
  27. package/dist/cli/src/doctor.js +51 -0
  28. package/dist/cli/src/kit.js +66 -0
  29. package/dist/cli/src/main.js +109 -0
  30. package/dist/cli/src/new.js +74 -0
  31. package/dist/cli/src/tools.js +104 -0
  32. package/kit.pin +9 -0
  33. package/package.json +81 -4
  34. package/rust-toolchain.toml +6 -0
  35. package/starters/ckx.json +9 -0
  36. package/starters/grid-mod/Cargo.toml +13 -0
  37. package/starters/grid-mod/src/lib.rs +82 -0
  38. package/starters/matchmaker/Cargo.toml +13 -0
  39. package/starters/matchmaker/src/lib.rs +362 -0
  40. package/starters/npc-mobs/Cargo.toml +14 -0
  41. package/starters/npc-mobs/src/lib.rs +469 -0
  42. package/starters/session/Cargo.toml +13 -0
  43. package/starters/session/src/lib.rs +395 -0
  44. package/starters/world-tick/Cargo.toml +14 -0
  45. package/starters/world-tick/src/lib.rs +346 -0
  46. package/templates/client/Cargo.toml +16 -0
  47. package/templates/client/src/lib.rs +31 -0
  48. package/templates/hub/Cargo.toml +11 -0
  49. package/templates/hub/src/lib.rs +73 -0
  50. package/templates/spoke/Cargo.toml +11 -0
  51. package/templates/spoke/src/lib.rs +53 -0
  52. package/tools/instrument/Cargo.lock +140 -0
  53. package/tools/instrument/Cargo.toml +11 -0
  54. package/tools/instrument/src/main.rs +165 -0
  55. package/vendor.lock +167 -0
@@ -0,0 +1,942 @@
1
+ //! Typed node API operations: the platform's world, grid, player, permission and access data,
2
+ //! always within this instance's app. Each needs a scope its node type declares in the manifest
3
+ //! (`"scopes": ["world.read", ...]`); without it the call fails with [`Status::Denied`].
4
+ //!
5
+ //! | Scope | Operations |
6
+ //! |---|---|
7
+ //! | `players.read` | [`Players::get`], [`Players::features`], [`Players::check_permission`], [`Players::state`], [`Players::avatar_state`] |
8
+ //! | `players.write` | [`Players::set_state`] |
9
+ //! | `world.read` | [`World::chunk`], [`World::voxels`], [`World::actors`], [`World::actors_near`] |
10
+ //! | `world.write` | [`World::set_voxels`], [`World::set_chunk_state`] |
11
+ //! | `grids.read` | [`Grids::get`], [`Grids::at`], [`Grids::check_permission`], [`Grids::settings`] |
12
+ //! | `grids.write` | [`Grids::create`], [`Grids::delete`], [`Grids::assign_group`], [`Grids::unassign_group`], [`Grids::set_open_permissions`] |
13
+ //! | `permissions.write` | [`Permissions::grant`], [`Permissions::revoke`] |
14
+ //! | `access.read` | [`Access::get`] |
15
+ //! | `access.write` | [`Access::grant`], [`Access::revoke`], [`Access::suspend`], [`Access::unsuspend`] |
16
+ //!
17
+ //! The game API rate limits each app (reads and writes separately); a refusal is
18
+ //! [`Status::RateLimited`].
19
+
20
+ use base64::Engine as _;
21
+ use base64::engine::general_purpose::STANDARD as B64;
22
+ use serde::de::{DeserializeOwned, Deserializer};
23
+ use serde::{Deserialize, Serialize, Serializer};
24
+ use serde_json::{Value, json};
25
+
26
+ use crate::{CallError, Ctx, Status};
27
+
28
+ /// Voxel writes in one [`World::set_voxels`] call.
29
+ pub const MAX_VOXEL_WRITES: usize = 16;
30
+ /// A voxel's state bytes.
31
+ pub const MAX_VOXEL_STATE: usize = 1024;
32
+ /// A chunk's state bytes ([`World::set_chunk_state`]).
33
+ pub const MAX_CHUNK_STATE: usize = 65_536;
34
+
35
+ /// A chunk's coordinates.
36
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
37
+ pub struct ChunkPos {
38
+ pub x: i64,
39
+ pub y: i64,
40
+ pub z: i64,
41
+ }
42
+
43
+ impl ChunkPos {
44
+ pub const fn new(x: i64, y: i64, z: i64) -> Self {
45
+ Self { x, y, z }
46
+ }
47
+
48
+ fn json(self) -> Value {
49
+ json!({ "x": self.x.to_string(), "y": self.y.to_string(), "z": self.z.to_string() })
50
+ }
51
+ }
52
+
53
+ /// A chunk as stored: the dense voxel grid and the game's own metadata blob.
54
+ #[derive(Debug, Clone, PartialEq)]
55
+ pub struct Chunk {
56
+ pub id: u64,
57
+ pub at: ChunkPos,
58
+ pub voxels: Option<Vec<u8>>,
59
+ pub chunk_state: Option<Vec<u8>>,
60
+ }
61
+
62
+ /// A voxel written into a chunk (the edits on top of its dense grid), with who wrote it and
63
+ /// when.
64
+ #[derive(Debug, Clone, PartialEq)]
65
+ pub struct Voxel {
66
+ pub x: i16,
67
+ pub y: i16,
68
+ pub z: i16,
69
+ /// What a type means is up to the app.
70
+ pub voxel_type: i16,
71
+ pub state: Option<Vec<u8>>,
72
+ /// Who wrote it: a user id in decimal, `"0"` when the app's own code did. `None` from a
73
+ /// platform that does not say.
74
+ pub created_by: Option<String>,
75
+ /// When it was written (RFC 3339).
76
+ pub updated_at: Option<String>,
77
+ }
78
+
79
+ /// One voxel to write: `voxel` is its position within `chunk`. The node API refuses a position
80
+ /// outside the range below (since ck-api v2.29.0).
81
+ #[derive(Debug, Clone, PartialEq)]
82
+ pub struct VoxelWrite {
83
+ pub chunk: ChunkPos,
84
+ /// `(x, y, z)` within the chunk: 0 to 15 on each axis.
85
+ pub voxel: (i16, i16, i16),
86
+ /// What a type means is up to the app.
87
+ pub voxel_type: i16,
88
+ pub state: Option<Vec<u8>>,
89
+ }
90
+
91
+ /// A player's actor in the world, from live presence.
92
+ #[derive(Debug, Clone, PartialEq)]
93
+ pub struct Actor {
94
+ /// The 32-character actor id.
95
+ pub uuid: String,
96
+ pub player: Option<u64>,
97
+ pub chunk: ChunkPos,
98
+ /// The actor's persisted public state, where the game keeps one.
99
+ pub state: Option<Vec<u8>>,
100
+ }
101
+
102
+ /// A grid: a box of chunks with its own permissions.
103
+ #[derive(Debug, Clone, PartialEq, Eq)]
104
+ pub struct Grid {
105
+ pub id: u64,
106
+ pub low: ChunkPos,
107
+ pub high: ChunkPos,
108
+ /// Who holds it, where the platform records an owner.
109
+ pub owner: Option<GridOwner>,
110
+ /// Permission keys every player holds on it, granted or not.
111
+ pub open_permissions: Vec<String>,
112
+ }
113
+
114
+ impl Grid {
115
+ pub fn contains(&self, at: ChunkPos) -> bool {
116
+ (self.low.x..=self.high.x).contains(&at.x)
117
+ && (self.low.y..=self.high.y).contains(&at.y)
118
+ && (self.low.z..=self.high.z).contains(&at.z)
119
+ }
120
+ }
121
+
122
+ /// A grid's owner: what kind of party it is, and which.
123
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124
+ pub struct GridOwner {
125
+ pub kind: String,
126
+ #[serde(rename = "ref")]
127
+ pub ref_: String,
128
+ }
129
+
130
+ /// The app's grid rules, from [`Grids::settings`].
131
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
132
+ #[serde(rename_all = "camelCase")]
133
+ pub struct GridSettings {
134
+ /// Players may write voxels outside every grid.
135
+ pub wilderness_writes_open: bool,
136
+ }
137
+
138
+ /// Which grid [`Grids::at`] returns where grids overlap.
139
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
140
+ pub enum GridPick {
141
+ /// The lowest grid id: what the realtime tier enforces.
142
+ #[default]
143
+ First,
144
+ Smallest,
145
+ Largest,
146
+ }
147
+
148
+ impl GridPick {
149
+ fn as_str(self) -> &'static str {
150
+ match self {
151
+ GridPick::First => "first",
152
+ GridPick::Smallest => "smallest",
153
+ GridPick::Largest => "largest",
154
+ }
155
+ }
156
+ }
157
+
158
+ /// A player's public identity.
159
+ #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
160
+ pub struct Player {
161
+ #[serde(deserialize_with = "de_u64")]
162
+ pub player: u64,
163
+ pub gamertag: String,
164
+ /// What tells players with the same gamertag apart; most players have none.
165
+ #[serde(default)]
166
+ pub disambiguation: Option<String>,
167
+ }
168
+
169
+ /// A player's access to the app, from [`Access::get`] and [`Access::grant`].
170
+ #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
171
+ #[serde(rename_all = "camelCase")]
172
+ pub struct PlayerAccess {
173
+ /// `active`, `revoked`, or another state the platform names.
174
+ pub status: String,
175
+ /// The access tier, if any.
176
+ #[serde(default, rename = "tierId", deserialize_with = "de_opt_u64")]
177
+ pub tier: Option<u64>,
178
+ /// When it ends (RFC 3339), if it does.
179
+ #[serde(default)]
180
+ pub expires_at: Option<String>,
181
+ /// Until when the player is suspended (RFC 3339), while they are.
182
+ #[serde(default)]
183
+ pub suspended_until: Option<String>,
184
+ }
185
+
186
+ fn bad_reply(op: &str, e: impl std::fmt::Display) -> CallError {
187
+ CallError { status: Status::Internal, message: format!("unexpected {op} reply: {e}") }
188
+ }
189
+
190
+ fn bad_request(message: impl Into<String>) -> CallError {
191
+ CallError { status: Status::BadRequest, message: message.into() }
192
+ }
193
+
194
+ fn op<R: DeserializeOwned>(ctx: &Ctx, name: &str, body: Value) -> Result<R, CallError> {
195
+ let body = serde_json::to_vec(&body).map_err(|e| bad_request(e.to_string()))?;
196
+ let bytes = send(ctx, name, &body)?;
197
+ serde_json::from_slice(&bytes).map_err(|e| bad_reply(name, e))
198
+ }
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
+ fn b64(bytes: &[u8]) -> String {
212
+ B64.encode(bytes)
213
+ }
214
+
215
+ // ---- wire shapes ----
216
+
217
+ #[derive(Deserialize)]
218
+ #[serde(untagged)]
219
+ enum Num {
220
+ Text(String),
221
+ Int(i64),
222
+ }
223
+
224
+ fn de_i64<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
225
+ match Num::deserialize(d)? {
226
+ Num::Text(s) => s.parse().map_err(serde::de::Error::custom),
227
+ Num::Int(i) => Ok(i),
228
+ }
229
+ }
230
+
231
+ fn de_u64<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
232
+ let v = de_i64(d)?;
233
+ u64::try_from(v).map_err(serde::de::Error::custom)
234
+ }
235
+
236
+ fn de_opt_u64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> {
237
+ match Option::<Num>::deserialize(d)? {
238
+ None => Ok(None),
239
+ Some(Num::Text(s)) => s.parse().map(Some).map_err(serde::de::Error::custom),
240
+ Some(Num::Int(i)) => u64::try_from(i).map(Some).map_err(serde::de::Error::custom),
241
+ }
242
+ }
243
+
244
+ /// An id or a time the platform may send as text or as a number, kept as text.
245
+ fn de_opt_text<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
246
+ match Option::<Value>::deserialize(d)? {
247
+ None | Some(Value::Null) => Ok(None),
248
+ Some(Value::String(s)) => Ok(Some(s)),
249
+ Some(Value::Number(n)) => Ok(Some(n.to_string())),
250
+ Some(other) => Err(serde::de::Error::custom(format!("expected text or a number, got {other}"))),
251
+ }
252
+ }
253
+
254
+ fn de_b64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
255
+ match Option::<String>::deserialize(d)? {
256
+ None => Ok(None),
257
+ Some(s) => B64.decode(s).map(Some).map_err(serde::de::Error::custom),
258
+ }
259
+ }
260
+
261
+ #[derive(Deserialize)]
262
+ struct WirePos {
263
+ #[serde(deserialize_with = "de_i64")]
264
+ x: i64,
265
+ #[serde(deserialize_with = "de_i64")]
266
+ y: i64,
267
+ #[serde(deserialize_with = "de_i64")]
268
+ z: i64,
269
+ }
270
+
271
+ impl From<WirePos> for ChunkPos {
272
+ fn from(p: WirePos) -> Self {
273
+ ChunkPos { x: p.x, y: p.y, z: p.z }
274
+ }
275
+ }
276
+
277
+ #[derive(Deserialize)]
278
+ #[serde(rename_all = "camelCase")]
279
+ struct WireChunk {
280
+ #[serde(deserialize_with = "de_u64")]
281
+ chunk_id: u64,
282
+ #[serde(flatten)]
283
+ at: WirePos,
284
+ #[serde(default, deserialize_with = "de_b64")]
285
+ state_base64: Option<Vec<u8>>,
286
+ #[serde(default, deserialize_with = "de_b64")]
287
+ chunk_state_base64: Option<Vec<u8>>,
288
+ }
289
+
290
+ #[derive(Deserialize)]
291
+ #[serde(rename_all = "camelCase")]
292
+ struct WireVoxel {
293
+ voxel_x: i16,
294
+ voxel_y: i16,
295
+ voxel_z: i16,
296
+ voxel_type: i16,
297
+ #[serde(default, deserialize_with = "de_b64")]
298
+ state_base64: Option<Vec<u8>>,
299
+ #[serde(default, deserialize_with = "de_opt_text")]
300
+ created_by: Option<String>,
301
+ #[serde(default, deserialize_with = "de_opt_text")]
302
+ updated_at: Option<String>,
303
+ }
304
+
305
+ impl From<WireVoxel> for Voxel {
306
+ fn from(v: WireVoxel) -> Self {
307
+ Voxel {
308
+ x: v.voxel_x,
309
+ y: v.voxel_y,
310
+ z: v.voxel_z,
311
+ voxel_type: v.voxel_type,
312
+ state: v.state_base64,
313
+ created_by: v.created_by,
314
+ updated_at: v.updated_at,
315
+ }
316
+ }
317
+ }
318
+
319
+ #[derive(Deserialize)]
320
+ #[serde(rename_all = "camelCase")]
321
+ struct WireActor {
322
+ uuid: String,
323
+ #[serde(default, deserialize_with = "de_opt_u64")]
324
+ user_id: Option<u64>,
325
+ #[serde(deserialize_with = "de_i64")]
326
+ chunk_x: i64,
327
+ #[serde(deserialize_with = "de_i64")]
328
+ chunk_y: i64,
329
+ #[serde(deserialize_with = "de_i64")]
330
+ chunk_z: i64,
331
+ #[serde(default, deserialize_with = "de_b64")]
332
+ state_base64: Option<Vec<u8>>,
333
+ }
334
+
335
+ #[derive(Deserialize)]
336
+ #[serde(rename_all = "camelCase")]
337
+ struct WireGrid {
338
+ #[serde(deserialize_with = "de_u64")]
339
+ grid_id: u64,
340
+ low: WirePos,
341
+ high: WirePos,
342
+ #[serde(default)]
343
+ owner: Option<GridOwner>,
344
+ #[serde(default)]
345
+ open_permissions: Vec<String>,
346
+ }
347
+
348
+ impl From<WireGrid> for Grid {
349
+ fn from(g: WireGrid) -> Self {
350
+ Grid { id: g.grid_id, low: g.low.into(), high: g.high.into(), owner: g.owner, open_permissions: g.open_permissions }
351
+ }
352
+ }
353
+
354
+ #[derive(Deserialize)]
355
+ #[serde(rename_all = "camelCase")]
356
+ struct WireState {
357
+ #[serde(default, deserialize_with = "de_b64")]
358
+ state_base64: Option<Vec<u8>>,
359
+ }
360
+
361
+ fn ser_id<S: Serializer>(v: &u64, s: S) -> Result<S::Ok, S::Error> {
362
+ s.serialize_str(&v.to_string())
363
+ }
364
+
365
+ #[derive(Serialize)]
366
+ struct PlayerReq {
367
+ #[serde(serialize_with = "ser_id")]
368
+ player: u64,
369
+ }
370
+
371
+ // ---- the operations ----
372
+
373
+ /// World reads and writes: [`Ctx::world`].
374
+ pub struct World<'a>(pub(crate) &'a Ctx);
375
+
376
+ impl World<'_> {
377
+ /// The chunk at `at`, or `None` when the app never stored it. Scope `world.read`.
378
+ pub fn chunk(&self, at: ChunkPos) -> Result<Option<Chunk>, CallError> {
379
+ #[derive(Deserialize)]
380
+ struct R {
381
+ chunk: Option<WireChunk>,
382
+ }
383
+ let r: R = op(self.0, "world.chunk", at.json())?;
384
+ Ok(r.chunk.map(|c| Chunk { id: c.chunk_id, at: c.at.into(), voxels: c.state_base64, chunk_state: c.chunk_state_base64 }))
385
+ }
386
+
387
+ /// The voxels written into a chunk, at most 2,048. Scope `world.read`.
388
+ pub fn voxels(&self, at: ChunkPos) -> Result<Vec<Voxel>, CallError> {
389
+ #[derive(Deserialize)]
390
+ struct R {
391
+ voxels: Vec<WireVoxel>,
392
+ }
393
+ let r: R = op(self.0, "world.voxels", at.json())?;
394
+ Ok(r.voxels.into_iter().map(Voxel::from).collect())
395
+ }
396
+
397
+ /// Writes up to [`MAX_VOXEL_WRITES`] voxels in one transaction, as the app, and shows them
398
+ /// to nearby players. Returns the voxel update ids. Scope `world.write`.
399
+ pub fn set_voxels(&self, writes: &[VoxelWrite]) -> Result<Vec<u64>, CallError> {
400
+ if writes.is_empty() || writes.len() > MAX_VOXEL_WRITES {
401
+ return Err(bad_request(format!("write 1 to {MAX_VOXEL_WRITES} voxels per call")));
402
+ }
403
+ let mut list = Vec::with_capacity(writes.len());
404
+ for w in writes {
405
+ if w.state.as_ref().is_some_and(|s| s.len() > MAX_VOXEL_STATE) {
406
+ return Err(bad_request(format!("a voxel's state is at most {MAX_VOXEL_STATE} bytes")));
407
+ }
408
+ list.push(json!({
409
+ "chunk": w.chunk.json(),
410
+ "voxel": { "x": w.voxel.0, "y": w.voxel.1, "z": w.voxel.2 },
411
+ "voxelType": w.voxel_type,
412
+ "stateBase64": w.state.as_deref().map(b64),
413
+ }));
414
+ }
415
+ #[derive(Deserialize)]
416
+ #[serde(rename_all = "camelCase")]
417
+ struct R {
418
+ voxel_update_ids: Vec<String>,
419
+ }
420
+ let r: R = op(self.0, "world.set_voxels", json!({ "writes": list }))?;
421
+ r.voxel_update_ids.iter().map(|id| id.parse().map_err(|e| bad_reply("world.set_voxels", e))).collect()
422
+ }
423
+
424
+ /// Replaces the chunk's own state blob, the one [`Chunk::chunk_state`] reads (at most
425
+ /// [`MAX_CHUNK_STATE`] bytes; `None` clears it). Scope `world.write`.
426
+ pub fn set_chunk_state(&self, chunk: ChunkPos, state: Option<&[u8]>) -> Result<(), CallError> {
427
+ if state.is_some_and(|s| s.len() > MAX_CHUNK_STATE) {
428
+ return Err(bad_request(format!("a chunk's state is at most {MAX_CHUNK_STATE} bytes")));
429
+ }
430
+ let _: Value = op(self.0, "world.set_chunk_state", json!({ "chunk": chunk.json(), "stateBase64": state.map(b64) }))?;
431
+ Ok(())
432
+ }
433
+
434
+ /// The actors in one chunk. Scope `world.read`.
435
+ pub fn actors(&self, at: ChunkPos) -> Result<Vec<Actor>, CallError> {
436
+ self.actors_op("world.actors", at.json())
437
+ }
438
+
439
+ /// The actors within `radius_xz` chunks across and `radius_y` up and down (at most 3 and 1).
440
+ /// Scope `world.read`.
441
+ pub fn actors_near(&self, center: ChunkPos, radius_xz: u8, radius_y: u8) -> Result<Vec<Actor>, CallError> {
442
+ let mut body = center.json();
443
+ body["radiusXz"] = json!(radius_xz.min(3));
444
+ body["radiusY"] = json!(radius_y.min(1));
445
+ self.actors_op("world.actors_radius", body)
446
+ }
447
+
448
+ fn actors_op(&self, name: &str, body: Value) -> Result<Vec<Actor>, CallError> {
449
+ #[derive(Deserialize)]
450
+ struct R {
451
+ actors: Vec<WireActor>,
452
+ }
453
+ let r: R = op(self.0, name, body)?;
454
+ Ok(r.actors
455
+ .into_iter()
456
+ .map(|a| Actor {
457
+ uuid: a.uuid,
458
+ player: a.user_id,
459
+ chunk: ChunkPos::new(a.chunk_x, a.chunk_y, a.chunk_z),
460
+ state: a.state_base64,
461
+ })
462
+ .collect())
463
+ }
464
+ }
465
+
466
+ /// Grids and their permissions: [`Ctx::grids`].
467
+ pub struct Grids<'a>(pub(crate) &'a Ctx);
468
+
469
+ impl Grids<'_> {
470
+ /// One of this app's grids. Scope `grids.read`.
471
+ pub fn get(&self, grid: u64) -> Result<Option<Grid>, CallError> {
472
+ self.grid_op(json!({ "grid": grid.to_string() }))
473
+ }
474
+
475
+ /// The grid covering chunk `at`, if any. Scope `grids.read`.
476
+ pub fn at(&self, at: ChunkPos, pick: GridPick) -> Result<Option<Grid>, CallError> {
477
+ self.grid_op(json!({ "at": at.json(), "pick": pick.as_str() }))
478
+ }
479
+
480
+ /// Whether `player` holds `permission` on `grid` (granted directly or through a group, and
481
+ /// not expired). Scope `grids.read`.
482
+ pub fn check_permission(&self, grid: u64, player: u64, permission: &str) -> Result<bool, CallError> {
483
+ #[derive(Deserialize)]
484
+ struct R {
485
+ allowed: bool,
486
+ }
487
+ let r: R = op(
488
+ self.0,
489
+ "grids.check_permission",
490
+ json!({ "grid": grid.to_string(), "player": player.to_string(), "permission": permission }),
491
+ )?;
492
+ Ok(r.allowed)
493
+ }
494
+
495
+ /// The app's grid rules. Scope `grids.read`.
496
+ pub fn settings(&self) -> Result<GridSettings, CallError> {
497
+ op(self.0, "grids.settings", json!({}))
498
+ }
499
+
500
+ /// A new grid of the app from chunk `low` to chunk `high`, both included. Scope
501
+ /// `grids.write`.
502
+ pub fn create(&self, low: ChunkPos, high: ChunkPos) -> Result<Grid, CallError> {
503
+ #[derive(Deserialize)]
504
+ struct R {
505
+ grid: WireGrid,
506
+ }
507
+ let r: R = op(self.0, "grids.create", json!({ "low": low.json(), "high": high.json() }))?;
508
+ Ok(r.grid.into())
509
+ }
510
+
511
+ /// Removes one of the app's grids. Scope `grids.write`.
512
+ pub fn delete(&self, grid: u64) -> Result<(), CallError> {
513
+ let _: Value = op(self.0, "grids.delete", json!({ "grid": grid.to_string() }))?;
514
+ Ok(())
515
+ }
516
+
517
+ /// Gives the members of one of the app's groups `permissions` on `grid`. Scope
518
+ /// `grids.write`.
519
+ pub fn assign_group(&self, grid: u64, group: u64, permissions: &[&str]) -> Result<(), CallError> {
520
+ let _: Value =
521
+ op(self.0, "grids.assign_group", json!({ "grid": grid.to_string(), "group": group.to_string(), "permissions": permissions }))?;
522
+ Ok(())
523
+ }
524
+
525
+ /// Takes back what [`Grids::assign_group`] gave the group on `grid`. Scope `grids.write`.
526
+ pub fn unassign_group(&self, grid: u64, group: u64) -> Result<(), CallError> {
527
+ let _: Value = op(self.0, "grids.unassign_group", json!({ "grid": grid.to_string(), "group": group.to_string() }))?;
528
+ Ok(())
529
+ }
530
+
531
+ /// Replaces the permission keys every player holds on `grid` ([`Grid::open_permissions`]).
532
+ /// Scope `grids.write`.
533
+ pub fn set_open_permissions(&self, grid: u64, permissions: &[&str]) -> Result<(), CallError> {
534
+ let _: Value = op(self.0, "grids.set_open_permissions", json!({ "grid": grid.to_string(), "permissions": permissions }))?;
535
+ Ok(())
536
+ }
537
+
538
+ fn grid_op(&self, body: Value) -> Result<Option<Grid>, CallError> {
539
+ #[derive(Deserialize)]
540
+ struct R {
541
+ grid: Option<WireGrid>,
542
+ }
543
+ let r: R = op(self.0, "grids.get", body)?;
544
+ Ok(r.grid.map(Grid::from))
545
+ }
546
+ }
547
+
548
+ /// Player lookups and app state: [`Ctx::players`].
549
+ pub struct Players<'a>(pub(crate) &'a Ctx);
550
+
551
+ impl Players<'_> {
552
+ /// A player's public identity, for players currently in this app. Scope `players.read`.
553
+ pub fn get(&self, player: u64) -> Result<Player, CallError> {
554
+ op(self.0, "players.get", serde_json::to_value(PlayerReq { player }).map_err(|e| bad_request(e.to_string()))?)
555
+ }
556
+
557
+ /// The feature keys the player's access tier grants in this app. Scope `players.read`.
558
+ pub fn features(&self, player: u64) -> Result<Vec<String>, CallError> {
559
+ #[derive(Deserialize)]
560
+ struct R {
561
+ features: Vec<String>,
562
+ }
563
+ let r: R = op(self.0, "players.features", json!({ "player": player.to_string() }))?;
564
+ Ok(r.features)
565
+ }
566
+
567
+ /// Whether the player is an active member of one of this app's groups (teams, channels), or
568
+ /// holds `permission` in it when one is named. Scope `players.read`.
569
+ pub fn check_permission(&self, player: u64, group: u64, permission: Option<&str>) -> Result<bool, CallError> {
570
+ #[derive(Deserialize)]
571
+ struct R {
572
+ allowed: bool,
573
+ }
574
+ let r: R = op(
575
+ self.0,
576
+ "players.check_permission",
577
+ json!({ "player": player.to_string(), "group": group.to_string(), "permission": permission }),
578
+ )?;
579
+ Ok(r.allowed)
580
+ }
581
+
582
+ /// The player's state blob for this app, which game clients read too. Scope `players.read`.
583
+ pub fn state(&self, player: u64) -> Result<Option<Vec<u8>>, CallError> {
584
+ self.state_op("players.state", json!({ "player": player.to_string() }))
585
+ }
586
+
587
+ /// Replaces the player's state blob for this app (at most 256 KiB; `None` clears it). The
588
+ /// player must have access to the app. Scope `players.write`.
589
+ pub fn set_state(&self, player: u64, state: Option<&[u8]>) -> Result<(), CallError> {
590
+ let _: Value = op(self.0, "players.set_state", json!({ "player": player.to_string(), "stateBase64": state.map(b64) }))?;
591
+ Ok(())
592
+ }
593
+
594
+ /// An avatar's state blob for this app. Scope `players.read`.
595
+ pub fn avatar_state(&self, avatar: u64) -> Result<Option<Vec<u8>>, CallError> {
596
+ self.state_op("players.avatar_state", json!({ "avatar": avatar.to_string() }))
597
+ }
598
+
599
+ fn state_op(&self, name: &str, body: Value) -> Result<Option<Vec<u8>>, CallError> {
600
+ #[derive(Deserialize)]
601
+ struct R {
602
+ state: Option<WireState>,
603
+ }
604
+ let r: R = op(self.0, name, body)?;
605
+ Ok(r.state.and_then(|s| s.state_base64))
606
+ }
607
+ }
608
+
609
+ /// Grid permission grants: [`Ctx::permissions`].
610
+ pub struct Permissions<'a>(pub(crate) &'a Ctx);
611
+
612
+ impl Permissions<'_> {
613
+ /// Grants runtime permission keys on one of this app's grids to a player with access to the
614
+ /// app, for `ttl_secs` or until revoked. Returns when the grant expires (RFC 3339), if it
615
+ /// does. Scope `permissions.write`.
616
+ pub fn grant(&self, grid: u64, player: u64, keys: &[&str], ttl_secs: Option<u32>) -> Result<Option<String>, CallError> {
617
+ #[derive(Deserialize)]
618
+ #[serde(rename_all = "camelCase")]
619
+ struct R {
620
+ expires_at: Option<String>,
621
+ }
622
+ let r: R = op(
623
+ self.0,
624
+ "permissions.grant",
625
+ json!({ "grid": grid.to_string(), "player": player.to_string(), "permissions": keys, "ttlSeconds": ttl_secs }),
626
+ )?;
627
+ Ok(r.expires_at)
628
+ }
629
+
630
+ /// Revokes the named keys, or every key when `keys` is `None`. Scope `permissions.write`.
631
+ pub fn revoke(&self, grid: u64, player: u64, keys: Option<&[&str]>) -> Result<(), CallError> {
632
+ let _: Value =
633
+ op(self.0, "permissions.revoke", json!({ "grid": grid.to_string(), "player": player.to_string(), "permissions": keys }))?;
634
+ Ok(())
635
+ }
636
+ }
637
+
638
+ /// Players' access to this app: [`Ctx::access`].
639
+ pub struct Access<'a>(pub(crate) &'a Ctx);
640
+
641
+ impl Access<'_> {
642
+ /// The player's access to the app, or `None` when they never had any. Scope `access.read`.
643
+ pub fn get(&self, player: u64) -> Result<Option<PlayerAccess>, CallError> {
644
+ #[derive(Deserialize)]
645
+ struct R {
646
+ access: Option<PlayerAccess>,
647
+ }
648
+ let r: R = op(self.0, "access.get", json!({ "player": player.to_string() }))?;
649
+ Ok(r.access)
650
+ }
651
+
652
+ /// Gives the player access to the app, in access tier `tier` when one is named. Scope
653
+ /// `access.write`.
654
+ pub fn grant(&self, player: u64, tier: Option<u64>) -> Result<PlayerAccess, CallError> {
655
+ #[derive(Deserialize)]
656
+ struct R {
657
+ access: PlayerAccess,
658
+ }
659
+ let mut body = json!({ "player": player.to_string() });
660
+ if let Some(tier) = tier {
661
+ body["tier"] = json!(tier.to_string());
662
+ }
663
+ let r: R = op(self.0, "access.grant", body)?;
664
+ Ok(r.access)
665
+ }
666
+
667
+ /// Takes the player's access away. Scope `access.write`.
668
+ pub fn revoke(&self, player: u64) -> Result<(), CallError> {
669
+ let _: Value = op(self.0, "access.revoke", json!({ "player": player.to_string() }))?;
670
+ Ok(())
671
+ }
672
+
673
+ /// Suspends the player's access for `seconds`. Returns until when (RFC 3339). Scope
674
+ /// `access.write`.
675
+ pub fn suspend(&self, player: u64, seconds: u32) -> Result<String, CallError> {
676
+ #[derive(Deserialize)]
677
+ #[serde(rename_all = "camelCase")]
678
+ struct R {
679
+ suspended_until: String,
680
+ }
681
+ let r: R = op(self.0, "access.suspend", json!({ "player": player.to_string(), "seconds": seconds }))?;
682
+ Ok(r.suspended_until)
683
+ }
684
+
685
+ /// Ends the player's suspension. Scope `access.write`.
686
+ pub fn unsuspend(&self, player: u64) -> Result<(), CallError> {
687
+ let _: Value = op(self.0, "access.unsuspend", json!({ "player": player.to_string() }))?;
688
+ Ok(())
689
+ }
690
+ }
691
+
692
+ #[cfg(test)]
693
+ mod tests {
694
+ use std::cell::RefCell;
695
+ use std::collections::VecDeque;
696
+
697
+ use super::*;
698
+ use crate::Kind;
699
+
700
+ /// The game API as the tests stand it in: every operation sent, with its body, and the
701
+ /// replies to give, in order.
702
+ #[derive(Default)]
703
+ pub(super) struct FakeNode {
704
+ sent: Vec<(String, Value)>,
705
+ replies: VecDeque<Value>,
706
+ }
707
+
708
+ impl FakeNode {
709
+ pub(super) fn answer(&mut self, name: &str, body: &[u8]) -> Result<Vec<u8>, CallError> {
710
+ self.sent.push((name.to_owned(), serde_json::from_slice(body).expect("a JSON body")));
711
+ Ok(serde_json::to_vec(&self.replies.pop_front().expect("a reply for every operation")).unwrap())
712
+ }
713
+ }
714
+
715
+ thread_local! {
716
+ pub(super) static NODE: RefCell<FakeNode> = RefCell::default();
717
+ }
718
+
719
+ /// Runs `f`, the game API answering its one operation with `reply`: what `f` returned, and
720
+ /// the operation and body it sent.
721
+ fn exchange<T>(reply: Value, f: impl FnOnce(&Ctx) -> T) -> (T, String, Value) {
722
+ NODE.with(|n| *n.borrow_mut() = FakeNode { sent: Vec::new(), replies: VecDeque::from([reply]) });
723
+ let ctx = Ctx { app: 7, instance: 1, epoch: 1, version: 1, kind: Kind::Hub, node_type: "t".into(), key: String::new() };
724
+ let out = f(&ctx);
725
+ let (name, body) = NODE.with(|n| n.borrow_mut().sent.pop()).expect("one operation sent");
726
+ (out, name, body)
727
+ }
728
+
729
+ /// `f` is refused before anything is sent.
730
+ fn refused_here<T: std::fmt::Debug>(f: impl FnOnce(&Ctx) -> Result<T, CallError>) -> CallError {
731
+ NODE.with(|n| *n.borrow_mut() = FakeNode::default());
732
+ let ctx = Ctx { app: 7, instance: 1, epoch: 1, version: 1, kind: Kind::Hub, node_type: "t".into(), key: String::new() };
733
+ let e = f(&ctx).expect_err("refused");
734
+ assert!(NODE.with(|n| n.borrow().sent.is_empty()), "nothing sent");
735
+ e
736
+ }
737
+
738
+ fn pos(x: i64, y: i64, z: i64) -> Value {
739
+ json!({ "x": x.to_string(), "y": y.to_string(), "z": z.to_string() })
740
+ }
741
+
742
+ #[test]
743
+ fn reads_ids_as_strings_or_numbers() {
744
+ let g: WireGrid = serde_json::from_value(json!({
745
+ "gridId": "12", "low": { "x": "-5", "y": 0, "z": "9223372036854775807" }, "high": { "x": 1, "y": 2, "z": 3 }, "createdAt": "x"
746
+ }))
747
+ .unwrap();
748
+ assert_eq!(g.grid_id, 12);
749
+ assert_eq!(ChunkPos::from(g.low), ChunkPos::new(-5, 0, i64::MAX));
750
+ let a: WireActor = serde_json::from_value(json!({
751
+ "uuid": "a", "uuidHex": "61", "userId": null, "stateBase64": "AQI=", "chunkX": "1", "chunkY": "2", "chunkZ": "-3", "updatedAt": 0
752
+ }))
753
+ .unwrap();
754
+ assert_eq!(a.user_id, None);
755
+ assert_eq!(a.state_base64, Some(vec![1, 2]));
756
+ let c: WireChunk = serde_json::from_value(json!({
757
+ "chunkId": "7", "x": "1", "y": "2", "z": "3", "stateBase64": null, "chunkStateBase64": "AA==", "updatedAt": 0
758
+ }))
759
+ .unwrap();
760
+ assert_eq!((c.chunk_id, c.chunk_state_base64), (7, Some(vec![0])));
761
+ }
762
+
763
+ #[test]
764
+ fn sends_coordinates_as_decimal_strings() {
765
+ assert_eq!(ChunkPos::new(i64::MIN, 0, 1).json(), json!({ "x": "-9223372036854775808", "y": "0", "z": "1" }));
766
+ let grid = Grid { id: 1, low: ChunkPos::new(0, 0, 0), high: ChunkPos::new(3, 3, 3), owner: None, open_permissions: vec![] };
767
+ assert!(grid.contains(ChunkPos::new(3, 0, 2)));
768
+ }
769
+
770
+ #[test]
771
+ fn a_player_without_a_disambiguation_reads() {
772
+ let reply = json!({ "player": "42", "gamertag": "Ada", "disambiguation": null });
773
+ let (got, name, body) = exchange(reply, |c| c.players().get(42));
774
+ assert_eq!((name.as_str(), body), ("players.get", json!({ "player": "42" })));
775
+ assert_eq!(got.unwrap(), Player { player: 42, gamertag: "Ada".into(), disambiguation: None });
776
+ let (got, ..) = exchange(json!({ "player": 42, "gamertag": "Ada" }), |c| c.players().get(42));
777
+ assert_eq!(got.unwrap().disambiguation, None, "a platform that leaves it out");
778
+ let (got, ..) = exchange(json!({ "player": "42", "gamertag": "Ada", "disambiguation": "0001" }), |c| c.players().get(42));
779
+ assert_eq!(got.unwrap().disambiguation.as_deref(), Some("0001"));
780
+ }
781
+
782
+ #[test]
783
+ fn voxels_are_signed_and_name_their_author_and_time() {
784
+ let reply = json!({ "voxels": [
785
+ { "voxelX": 1, "voxelY": 2, "voxelZ": 3, "voxelType": -300, "stateBase64": "Bw==", "createdBy": "42",
786
+ "updatedAt": "2026-10-09T12:00:00.000Z" },
787
+ { "voxelX": 0, "voxelY": 0, "voxelZ": 15, "voxelType": 32767, "stateBase64": null, "createdBy": "0", "updatedAt": "t" },
788
+ { "voxelX": 4, "voxelY": 4, "voxelZ": 4, "voxelType": 9 },
789
+ ] });
790
+ let (got, name, body) = exchange(reply, |c| c.world().voxels(ChunkPos::new(1, -2, 3)));
791
+ assert_eq!((name.as_str(), body), ("world.voxels", pos(1, -2, 3)));
792
+ let got = got.unwrap();
793
+ assert_eq!(
794
+ got[0],
795
+ Voxel {
796
+ x: 1,
797
+ y: 2,
798
+ z: 3,
799
+ voxel_type: -300,
800
+ state: Some(vec![7]),
801
+ created_by: Some("42".into()),
802
+ updated_at: Some("2026-10-09T12:00:00.000Z".into()),
803
+ }
804
+ );
805
+ assert_eq!((got[1].voxel_type, got[1].created_by.as_deref()), (i16::MAX, Some("0")), "\"0\" is the app's own code");
806
+ assert_eq!((got[2].created_by.clone(), got[2].updated_at.clone()), (None, None), "an older platform's voxel still reads");
807
+ let v: WireVoxel =
808
+ serde_json::from_value(json!({ "voxelX": 0, "voxelY": 0, "voxelZ": 0, "voxelType": 1, "createdBy": 42, "updatedAt": 5 }))
809
+ .unwrap();
810
+ assert_eq!((v.created_by.as_deref(), v.updated_at.as_deref()), (Some("42"), Some("5")), "numbers are kept as text");
811
+ }
812
+
813
+ #[test]
814
+ fn voxel_writes_carry_signed_types() {
815
+ let w = VoxelWrite { chunk: ChunkPos::new(0, 1, 2), voxel: (1, 2, 3), voxel_type: -2, state: Some(vec![7]) };
816
+ let (got, name, body) = exchange(json!({ "voxelUpdateIds": ["5"] }), |c| c.world().set_voxels(std::slice::from_ref(&w)));
817
+ assert_eq!(got.unwrap(), vec![5]);
818
+ assert_eq!(name, "world.set_voxels");
819
+ assert_eq!(
820
+ body,
821
+ json!({ "writes": [{ "chunk": pos(0, 1, 2), "voxel": { "x": 1, "y": 2, "z": 3 }, "voxelType": -2, "stateBase64": "Bw==" }] })
822
+ );
823
+ let big = VoxelWrite { state: Some(vec![0; MAX_VOXEL_STATE + 1]), ..w };
824
+ assert_eq!(refused_here(|c| c.world().set_voxels(&[big])).status, Status::BadRequest);
825
+ }
826
+
827
+ #[test]
828
+ fn a_chunk_state_is_set_or_cleared_within_its_cap() {
829
+ let (got, name, body) = exchange(json!({ "updatedAt": "2026-10-09T12:00:00.000Z" }), |c| {
830
+ c.world().set_chunk_state(ChunkPos::new(-1, 0, 9), Some(b"\x01\x02"))
831
+ });
832
+ got.unwrap();
833
+ assert_eq!((name.as_str(), body), ("world.set_chunk_state", json!({ "chunk": pos(-1, 0, 9), "stateBase64": "AQI=" })));
834
+ let (got, _, body) = exchange(json!({ "updatedAt": null }), |c| c.world().set_chunk_state(ChunkPos::new(0, 0, 0), None));
835
+ got.unwrap();
836
+ assert_eq!(body, json!({ "chunk": pos(0, 0, 0), "stateBase64": null }), "null clears it");
837
+ let (got, ..) =
838
+ exchange(json!({ "updatedAt": "t" }), |c| c.world().set_chunk_state(ChunkPos::default(), Some(&[0; MAX_CHUNK_STATE])));
839
+ assert!(got.is_ok(), "the cap itself is allowed");
840
+ let e = refused_here(|c| c.world().set_chunk_state(ChunkPos::default(), Some(&[0; MAX_CHUNK_STATE + 1])));
841
+ assert_eq!(e.status, Status::BadRequest);
842
+ }
843
+
844
+ #[test]
845
+ fn a_grid_names_its_owner_and_open_permissions() {
846
+ let reply = json!({ "grid": {
847
+ "gridId": "12", "low": pos(0, 0, 0), "high": pos(3, 1, 3), "owner": { "kind": "guild", "ref": "88" },
848
+ "openPermissions": ["enter", "use"], "createdAt": "t"
849
+ } });
850
+ let (got, name, body) = exchange(reply, |c| c.grids().get(12));
851
+ assert_eq!((name.as_str(), body), ("grids.get", json!({ "grid": "12" })));
852
+ let g = got.unwrap().unwrap();
853
+ assert_eq!(g.owner, Some(GridOwner { kind: "guild".into(), ref_: "88".into() }));
854
+ assert_eq!(g.open_permissions, vec!["enter".to_string(), "use".to_string()]);
855
+ assert_eq!(serde_json::to_value(g.owner.unwrap()).unwrap(), json!({ "kind": "guild", "ref": "88" }));
856
+ let (got, ..) = exchange(json!({ "grid": { "gridId": 1, "low": pos(0, 0, 0), "high": pos(0, 0, 0) } }), |c| c.grids().get(1));
857
+ let g = got.unwrap().unwrap();
858
+ assert_eq!((g.owner, g.open_permissions), (None, vec![]), "an older platform's grid still reads");
859
+ let (got, ..) = exchange(json!({ "grid": { "gridId": 1, "low": pos(0, 0, 0), "high": pos(0, 0, 0), "owner": null } }), |c| {
860
+ c.grids().at(ChunkPos::default(), GridPick::First)
861
+ });
862
+ assert_eq!(got.unwrap().unwrap().owner, None);
863
+ }
864
+
865
+ #[test]
866
+ fn grid_settings_and_writes_send_what_the_node_api_takes() {
867
+ let (got, name, body) = exchange(json!({ "wildernessWritesOpen": true }), |c| c.grids().settings());
868
+ assert_eq!((got.unwrap(), name.as_str(), body), (GridSettings { wilderness_writes_open: true }, "grids.settings", json!({})));
869
+
870
+ let created =
871
+ json!({ "grid": { "gridId": "77", "low": pos(-2, 0, -2), "high": pos(2, 1, 2), "owner": null, "openPermissions": [] } });
872
+ let (got, name, body) = exchange(created, |c| c.grids().create(ChunkPos::new(-2, 0, -2), ChunkPos::new(2, 1, 2)));
873
+ let g = got.unwrap();
874
+ assert_eq!((g.id, g.low, g.high), (77, ChunkPos::new(-2, 0, -2), ChunkPos::new(2, 1, 2)));
875
+ assert_eq!((name.as_str(), body), ("grids.create", json!({ "low": pos(-2, 0, -2), "high": pos(2, 1, 2) })));
876
+
877
+ let (got, name, body) = exchange(json!({}), |c| c.grids().delete(u64::MAX));
878
+ got.unwrap();
879
+ assert_eq!((name.as_str(), body), ("grids.delete", json!({ "grid": "18446744073709551615" })));
880
+
881
+ let (got, name, body) = exchange(json!({}), |c| c.grids().assign_group(77, 5, &["build", "enter"]));
882
+ got.unwrap();
883
+ assert_eq!((name.as_str(), body), ("grids.assign_group", json!({ "grid": "77", "group": "5", "permissions": ["build", "enter"] })));
884
+
885
+ let (got, name, body) = exchange(json!({}), |c| c.grids().unassign_group(77, 5));
886
+ got.unwrap();
887
+ assert_eq!((name.as_str(), body), ("grids.unassign_group", json!({ "grid": "77", "group": "5" })));
888
+
889
+ let (got, name, body) = exchange(json!({}), |c| c.grids().set_open_permissions(77, &["enter"]));
890
+ got.unwrap();
891
+ assert_eq!((name.as_str(), body), ("grids.set_open_permissions", json!({ "grid": "77", "permissions": ["enter"] })));
892
+ let (got, _, body) = exchange(json!({}), |c| c.grids().set_open_permissions(77, &[]));
893
+ got.unwrap();
894
+ assert_eq!(body, json!({ "grid": "77", "permissions": [] }), "none clears them");
895
+ }
896
+
897
+ #[test]
898
+ fn access_is_read_granted_revoked_and_suspended() {
899
+ let active = json!({ "status": "active", "tierId": "3", "expiresAt": null, "suspendedUntil": null });
900
+ let (got, name, body) = exchange(json!({ "access": active }), |c| c.access().get(42));
901
+ assert_eq!((name.as_str(), body), ("access.get", json!({ "player": "42" })));
902
+ assert_eq!(got.unwrap(), Some(PlayerAccess { status: "active".into(), tier: Some(3), expires_at: None, suspended_until: None }));
903
+ let (got, ..) = exchange(json!({ "access": null }), |c| c.access().get(42));
904
+ assert_eq!(got.unwrap(), None, "never had any");
905
+ let suspended = json!({ "access": { "status": "active", "tierId": null, "expiresAt": "2027-01-01T00:00:00.000Z",
906
+ "suspendedUntil": "2026-10-10T00:00:00.000Z" } });
907
+ let (got, ..) = exchange(suspended, |c| c.access().get(42));
908
+ let a = got.unwrap().unwrap();
909
+ assert_eq!(
910
+ (a.tier, a.expires_at.as_deref(), a.suspended_until.as_deref()),
911
+ (None, Some("2027-01-01T00:00:00.000Z"), Some("2026-10-10T00:00:00.000Z"))
912
+ );
913
+
914
+ let granted = json!({ "access": { "status": "active", "tierId": 9, "expiresAt": null, "suspendedUntil": null } });
915
+ let (got, name, body) = exchange(granted.clone(), |c| c.access().grant(42, Some(9)));
916
+ assert_eq!(got.unwrap().tier, Some(9));
917
+ assert_eq!((name.as_str(), body), ("access.grant", json!({ "player": "42", "tier": "9" })));
918
+ let (got, _, body) = exchange(granted, |c| c.access().grant(42, None));
919
+ got.unwrap();
920
+ assert_eq!(body, json!({ "player": "42" }), "no tier, none named");
921
+
922
+ let (got, name, body) = exchange(json!({}), |c| c.access().revoke(42));
923
+ got.unwrap();
924
+ assert_eq!((name.as_str(), body), ("access.revoke", json!({ "player": "42" })));
925
+
926
+ let (got, name, body) = exchange(json!({ "suspendedUntil": "2026-10-10T00:00:00.000Z" }), |c| c.access().suspend(42, 3_600));
927
+ assert_eq!(got.unwrap(), "2026-10-10T00:00:00.000Z");
928
+ assert_eq!((name.as_str(), body), ("access.suspend", json!({ "player": "42", "seconds": 3600 })));
929
+
930
+ let (got, name, body) = exchange(json!({}), |c| c.access().unsuspend(42));
931
+ got.unwrap();
932
+ assert_eq!((name.as_str(), body), ("access.unsuspend", json!({ "player": "42" })));
933
+ }
934
+
935
+ #[test]
936
+ fn a_reply_that_does_not_read_is_internal() {
937
+ let (got, ..) = exchange(json!({ "suspended": true }), |c| c.access().suspend(42, 1));
938
+ let e = got.unwrap_err();
939
+ assert_eq!(e.status, Status::Internal);
940
+ assert!(e.message.contains("access.suspend"), "{}", e.message);
941
+ }
942
+ }