@crowdedkingdoms/ckx-kit 0.5.0 → 0.6.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.
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ckx-sdk"
3
- version = "0.12.1"
3
+ version = "0.13.0-dev.1"
4
4
  edition = "2024"
5
5
  description = "Write ck-exec hubs, spokes and mods for Crowded Kingdoms in Rust, compiled to wasm32-unknown-unknown"
6
6
  license = "MIT OR Apache-2.0"
@@ -31,15 +31,18 @@
31
31
  //! Realtime events players around see at once go through [`Ctx::emit_spatial`],
32
32
  //! [`Ctx::emit_actor`] and [`Ctx::emit_channel`]; see [`emit`].
33
33
  //!
34
- //! Platform data (world, grids, players, grid permissions, players' access to the app) is behind
35
- //! [`Ctx::world`], [`Ctx::grids`], [`Ctx::players`], [`Ctx::permissions`] and [`Ctx::access`],
36
- //! each needing a scope the node type declares in the manifest; see [`node`].
34
+ //! Platform data (world, grids, players, grid permissions, players' access to the app, safety
35
+ //! reports) is behind [`Ctx::world`], [`Ctx::grids`], [`Ctx::players`], [`Ctx::permissions`],
36
+ //! [`Ctx::access`] and [`Ctx::safety`], each needing a scope the node type declares in the
37
+ //! manifest; see [`node`].
37
38
  //!
38
39
  //! Hubs also get platform events, each a default no-op on [`Hub`]: timers they set
39
40
  //! ([`Hub::on_timer`]), topics they watch ([`Hub::on_topic`]), players entering and leaving
40
41
  //! the app ([`Hub::on_presence`], root hub only) and players' connections subscribing to them
41
42
  //! ([`Hub::on_session`]). A timer can follow a cron schedule, the legacy automations' syntax:
42
- //! see [`cron`] and [`Ctx::timer_cron`].
43
+ //! see [`cron`] and [`Ctx::timer_cron`]. Two more concern what a hub holds about a player:
44
+ //! erasing it when their account is erased ([`Hub::on_player_erased`]) and exporting it
45
+ //! ([`Hub::on_player_export`]); a hub that does not implement them answers "not implemented".
43
46
  //!
44
47
  //! Nodes are unit tested natively with `cargo test`: `testing` runs one against a fake of the
45
48
  //! platform (calls, timers, events, snapshots, the node API, a mod's grid). [`limits`] has the
@@ -63,12 +66,14 @@ pub mod rt;
63
66
  pub mod testing;
64
67
 
65
68
  use serde::{Deserialize, Serialize, de::DeserializeOwned};
69
+ /// What [`Hub::on_player_export`] answers in, for a crate that does not depend on it itself.
70
+ pub use serde_json;
66
71
 
67
72
  pub mod prelude {
68
73
  pub use crate::cron::Cron;
69
74
  pub use crate::emit::{Spatial, actor_id};
70
75
  pub use crate::model::{Model, PropKind, Schema, Viewer, Visibility};
71
- pub use crate::node::{ChunkPos, GridPick, VoxelWrite};
76
+ pub use crate::node::{AgeBracket, ChunkPos, GridPick, ReportReason, SafetyReport, VoxelWrite};
72
77
  pub use crate::{
73
78
  Call, CallError, Caller, Ctx, Error, Hub, Kind, Level, Presence, PresencePlayer, Result, Session, Spoke, Status, TopicMsg,
74
79
  WorldEvent, decode, encode,
@@ -88,6 +93,13 @@ impl Error {
88
93
  pub fn unknown_method(method: &str) -> Self {
89
94
  Self(format!("unknown method `{method}`"))
90
95
  }
96
+
97
+ /// What a hook the hub does not implement answers ([`Hub::on_player_erased`],
98
+ /// [`Hub::on_player_export`]). The platform tells it from a failure: it records it and goes
99
+ /// on.
100
+ pub fn not_implemented(hook: &str) -> Self {
101
+ Self(format!("not implemented: `{hook}`"))
102
+ }
91
103
  }
92
104
 
93
105
  impl std::fmt::Display for Error {
@@ -446,6 +458,11 @@ impl Ctx {
446
458
  node::Access(self)
447
459
  }
448
460
 
461
+ /// Safety reports a player files through this hub (scope `safety.write`).
462
+ pub fn safety(&self) -> node::Safety<'_> {
463
+ node::Safety(self)
464
+ }
465
+
449
466
  pub fn log(&self, level: Level, msg: &str) {
450
467
  abi::log(level as u32, msg);
451
468
  }
@@ -528,6 +545,22 @@ pub trait Hub: Sized + 'static {
528
545
  fn on_world(&mut self, _ctx: &Ctx, _events: &[WorldEvent]) -> Result<()> {
529
546
  Ok(())
530
547
  }
548
+ /// `player`'s account is being erased: delete or anonymize everything this hub holds about
549
+ /// them. Every hub of the app that holds state hears it, a stopped one started for it,
550
+ /// the player's own included (one keyed by their id, which the platform then deletes with
551
+ /// its snapshot). The platform counts the hub done once a snapshot taken after this returns
552
+ /// is stored; an error is recorded, and the erasure asks again later. Left out, the hub
553
+ /// answers "not implemented", which is recorded and does not hold the erasure up.
554
+ fn on_player_erased(&mut self, _ctx: &Ctx, _player: u64) -> Result<()> {
555
+ Err(Error::not_implemented("on_player_erased"))
556
+ }
557
+ /// What this hub holds about `player`, for their data export: JSON, at most
558
+ /// [`limits::MAX_EXPORT_BYTES`] of it, or `Value::Null` when it holds nothing. Every hub of
559
+ /// the app that holds state is asked, a stopped one started for it. Left out, the hub
560
+ /// answers "not implemented", which the export records.
561
+ fn on_player_export(&mut self, _ctx: &Ctx, _player: u64) -> Result<serde_json::Value> {
562
+ Err(Error::not_implemented("on_player_export"))
563
+ }
531
564
  }
532
565
 
533
566
  /// A stateless node. Replicas may run anywhere; keep nothing here you cannot lose.
@@ -19,6 +19,9 @@ pub const MAX_LOG_BYTES: usize = 4 << 10;
19
19
  pub const MAX_EMIT_BYTES: usize = 70 + crate::emit::MAX_PAYLOAD;
20
20
  /// The largest snapshot a hub may persist. A larger one fails to save, and the last one stands.
21
21
  pub const MAX_SNAPSHOT_BYTES: usize = 8 << 20;
22
+ /// The most JSON a hub's [`crate::Hub::on_player_export`] may answer. A larger answer is an
23
+ /// error, and the export records the hub as failed.
24
+ pub const MAX_EXPORT_BYTES: usize = 1 << 20;
22
25
  /// The largest module an app's node type may deploy.
23
26
  pub const ARTIFACT_MAX_BYTES: usize = 16 << 20;
24
27
 
@@ -1,10 +1,11 @@
1
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`].
2
+ //! and safety reports, always within this instance's app. Each needs a scope its node type
3
+ //! declares in the manifest (`"scopes": ["world.read", ...]`); without it the call fails with
4
+ //! [`Status::Denied`].
4
5
  //!
5
6
  //! | Scope | Operations |
6
7
  //! |---|---|
7
- //! | `players.read` | [`Players::get`], [`Players::features`], [`Players::check_permission`], [`Players::state`], [`Players::avatar_state`] |
8
+ //! | `players.read` | [`Players::get`], [`Players::features`], [`Players::check_permission`], [`Players::state`], [`Players::avatar_state`], [`Players::blocked`] |
8
9
  //! | `players.write` | [`Players::set_state`] |
9
10
  //! | `world.read` | [`World::chunk`], [`World::voxels`], [`World::actors`], [`World::actors_near`] |
10
11
  //! | `world.write` | [`World::set_voxels`], [`World::set_chunk_state`] |
@@ -13,6 +14,7 @@
13
14
  //! | `permissions.write` | [`Permissions::grant`], [`Permissions::revoke`] |
14
15
  //! | `access.read` | [`Access::get`] |
15
16
  //! | `access.write` | [`Access::grant`], [`Access::revoke`], [`Access::suspend`], [`Access::unsuspend`] |
17
+ //! | `safety.write` | [`Safety::report`] |
16
18
  //!
17
19
  //! The game API rate limits each app (reads and writes separately); a refusal is
18
20
  //! [`Status::RateLimited`].
@@ -31,6 +33,8 @@ pub const MAX_VOXEL_WRITES: usize = 16;
31
33
  pub const MAX_VOXEL_STATE: usize = 1024;
32
34
  /// A chunk's state bytes ([`World::set_chunk_state`]).
33
35
  pub const MAX_CHUNK_STATE: usize = 65_536;
36
+ /// A safety report's details, in characters as the game API counts them (UTF-16 units).
37
+ pub const MAX_REPORT_DETAILS: usize = 1_000;
34
38
 
35
39
  /// A chunk's coordinates.
36
40
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
@@ -164,6 +168,85 @@ pub struct Player {
164
168
  /// What tells players with the same gamertag apart; most players have none.
165
169
  #[serde(default)]
166
170
  pub disambiguation: Option<String>,
171
+ /// The player's age bracket; `None` from a platform that does not say.
172
+ #[serde(default, alias = "ageBracket")]
173
+ pub age_bracket: Option<AgeBracket>,
174
+ }
175
+
176
+ /// How old a player is, as a bracket: never their age itself.
177
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
178
+ pub enum AgeBracket {
179
+ /// No age is known: the platform refuses such a player a token, so a hub rarely sees one.
180
+ Unknown,
181
+ Under13,
182
+ From13To15,
183
+ From16To17,
184
+ Adult,
185
+ }
186
+
187
+ impl AgeBracket {
188
+ /// As the platform writes it: `unknown`, `under_13`, `13_15`, `16_17` or `18_plus`.
189
+ pub fn as_str(self) -> &'static str {
190
+ match self {
191
+ AgeBracket::Unknown => "unknown",
192
+ AgeBracket::Under13 => "under_13",
193
+ AgeBracket::From13To15 => "13_15",
194
+ AgeBracket::From16To17 => "16_17",
195
+ AgeBracket::Adult => "18_plus",
196
+ }
197
+ }
198
+
199
+ /// 13 to 17: the platform holds the player to minors' defaults.
200
+ pub fn is_minor(self) -> bool {
201
+ matches!(self, AgeBracket::From13To15 | AgeBracket::From16To17)
202
+ }
203
+ }
204
+
205
+ /// A bracket this SDK does not know reads as [`AgeBracket::Unknown`], the most guarded.
206
+ impl<'de> Deserialize<'de> for AgeBracket {
207
+ fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
208
+ let s = String::deserialize(d)?;
209
+ let known = [AgeBracket::Under13, AgeBracket::From13To15, AgeBracket::From16To17, AgeBracket::Adult];
210
+ Ok(known.into_iter().find(|b| b.as_str() == s).unwrap_or(AgeBracket::Unknown))
211
+ }
212
+ }
213
+
214
+ /// Why a player is reported ([`SafetyReport::reason`]).
215
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
216
+ #[serde(rename_all = "snake_case")]
217
+ pub enum ReportReason {
218
+ ChildSafety,
219
+ Harassment,
220
+ SexualContent,
221
+ Hate,
222
+ SelfHarm,
223
+ Cheating,
224
+ Other,
225
+ }
226
+
227
+ /// A safety report a hub files for one player about another ([`Safety::report`]).
228
+ #[derive(Debug, Clone, PartialEq, Eq)]
229
+ pub struct SafetyReport {
230
+ /// The player reporting.
231
+ pub reporter: u64,
232
+ /// The player reported.
233
+ pub reported: u64,
234
+ /// The reported player's actor (its 32-character id), when the report is about what it
235
+ /// did in the world.
236
+ pub reported_actor: Option<String>,
237
+ pub reason: ReportReason,
238
+ /// What happened, in the reporter's words: at most [`MAX_REPORT_DETAILS`] characters.
239
+ pub details: String,
240
+ /// When it happened, in Unix milliseconds.
241
+ pub occurred_at_ms: u64,
242
+ }
243
+
244
+ /// A report the platform recorded.
245
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
246
+ pub struct FiledReport {
247
+ /// What moderators know it by.
248
+ #[serde(deserialize_with = "de_u64", alias = "reportId")]
249
+ pub report_id: u64,
167
250
  }
168
251
 
169
252
  /// A player's access to the app, from [`Access::get`] and [`Access::grant`].
@@ -201,6 +284,21 @@ fn b64(bytes: &[u8]) -> String {
201
284
  B64.encode(bytes)
202
285
  }
203
286
 
287
+ /// Unix milliseconds as the game API writes a time: `2026-01-01T00:00:00.000Z`.
288
+ pub(crate) fn rfc3339(ms: u64) -> String {
289
+ let (secs, millis) = (ms / 1000, ms % 1000);
290
+ let (days, rem) = ((secs / 86_400) as i64, secs % 86_400);
291
+ let z = days + 719_468;
292
+ let (era, doe) = (z.div_euclid(146_097), z.rem_euclid(146_097));
293
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
294
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
295
+ let mp = (5 * doy + 2) / 153;
296
+ let day = doy - (153 * mp + 2) / 5 + 1;
297
+ let month = if mp < 10 { mp + 3 } else { mp - 9 };
298
+ let year = yoe + era * 400 + i64::from(month <= 2);
299
+ format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{millis:03}Z", rem / 3600, rem % 3600 / 60, rem % 60)
300
+ }
301
+
204
302
  // ---- wire shapes ----
205
303
 
206
304
  #[derive(Deserialize)]
@@ -585,6 +683,19 @@ impl Players<'_> {
585
683
  self.state_op("players.avatar_state", json!({ "avatar": avatar.to_string() }))
586
684
  }
587
685
 
686
+ /// Whether either player has blocked the other in this app: check it before mail, friend
687
+ /// requests, trades, duels or invites pass between them, and before the hub's own events
688
+ /// carry one's words to the other (the realtime servers pass what a hub emits, blocks or
689
+ /// not). Scope `players.read`.
690
+ pub fn blocked(&self, player: u64, other: u64) -> Result<bool, CallError> {
691
+ #[derive(Deserialize)]
692
+ struct R {
693
+ blocked: bool,
694
+ }
695
+ let r: R = op(self.0, "players.blocked", json!({ "player": player.to_string(), "other": other.to_string() }))?;
696
+ Ok(r.blocked)
697
+ }
698
+
588
699
  fn state_op(&self, name: &str, body: Value) -> Result<Option<Vec<u8>>, CallError> {
589
700
  #[derive(Deserialize)]
590
701
  struct R {
@@ -678,6 +789,31 @@ impl Access<'_> {
678
789
  }
679
790
  }
680
791
 
792
+ /// Safety reports: [`Ctx::safety`].
793
+ pub struct Safety<'a>(pub(crate) &'a Ctx);
794
+
795
+ impl Safety<'_> {
796
+ /// Files `report` as its reporter would from the game, both players being the app's:
797
+ /// moderators review it with what the app's safety log recorded of both around when it
798
+ /// happened. Scope `safety.write`.
799
+ pub fn report(&self, report: &SafetyReport) -> Result<FiledReport, CallError> {
800
+ if report.details.encode_utf16().count() > MAX_REPORT_DETAILS {
801
+ return Err(bad_request(format!("a report's details are at most {MAX_REPORT_DETAILS} characters")));
802
+ }
803
+ let mut body = json!({
804
+ "reporter": report.reporter.to_string(),
805
+ "reported": report.reported.to_string(),
806
+ "reason": report.reason,
807
+ "details": report.details,
808
+ "occurred_at": rfc3339(report.occurred_at_ms),
809
+ });
810
+ if let Some(actor) = &report.reported_actor {
811
+ body["reported_actor"] = json!(actor);
812
+ }
813
+ op(self.0, "safety.report", body)
814
+ }
815
+ }
816
+
681
817
  #[cfg(test)]
682
818
  mod tests {
683
819
  use std::cell::RefCell;
@@ -769,13 +905,98 @@ mod tests {
769
905
  let reply = json!({ "player": "42", "gamertag": "Ada", "disambiguation": null });
770
906
  let (got, name, body) = exchange(reply, |c| c.players().get(42));
771
907
  assert_eq!((name.as_str(), body), ("players.get", json!({ "player": "42" })));
772
- assert_eq!(got.unwrap(), Player { player: 42, gamertag: "Ada".into(), disambiguation: None });
908
+ assert_eq!(got.unwrap(), Player { player: 42, gamertag: "Ada".into(), disambiguation: None, age_bracket: None });
773
909
  let (got, ..) = exchange(json!({ "player": 42, "gamertag": "Ada" }), |c| c.players().get(42));
774
910
  assert_eq!(got.unwrap().disambiguation, None, "a platform that leaves it out");
775
911
  let (got, ..) = exchange(json!({ "player": "42", "gamertag": "Ada", "disambiguation": "0001" }), |c| c.players().get(42));
776
912
  assert_eq!(got.unwrap().disambiguation.as_deref(), Some("0001"));
777
913
  }
778
914
 
915
+ #[test]
916
+ fn a_player_names_their_age_bracket_and_an_older_platform_none() {
917
+ let get = |reply: Value| exchange(reply, |c| c.players().get(42)).0.unwrap().age_bracket;
918
+ assert_eq!(get(json!({ "player": "42", "gamertag": "Ada", "disambiguation": null })), None, "an older ck-api");
919
+ for (wire, bracket) in [
920
+ ("unknown", AgeBracket::Unknown),
921
+ ("under_13", AgeBracket::Under13),
922
+ ("13_15", AgeBracket::From13To15),
923
+ ("16_17", AgeBracket::From16To17),
924
+ ("18_plus", AgeBracket::Adult),
925
+ ] {
926
+ assert_eq!(get(json!({ "player": "42", "gamertag": "Ada", "age_bracket": wire })), Some(bracket), "{wire}");
927
+ assert_eq!(bracket.as_str(), wire);
928
+ }
929
+ assert_eq!(get(json!({ "player": "42", "gamertag": "Ada", "ageBracket": "16_17" })), Some(AgeBracket::From16To17));
930
+ assert_eq!(get(json!({ "player": "42", "gamertag": "Ada", "age_bracket": null })), None);
931
+ assert_eq!(
932
+ get(json!({ "player": "42", "gamertag": "Ada", "age_bracket": "21_plus" })),
933
+ Some(AgeBracket::Unknown),
934
+ "one it does not know"
935
+ );
936
+ assert!(AgeBracket::From13To15.is_minor() && AgeBracket::From16To17.is_minor());
937
+ assert!(!AgeBracket::Adult.is_minor() && !AgeBracket::Unknown.is_minor() && !AgeBracket::Under13.is_minor());
938
+ }
939
+
940
+ #[test]
941
+ fn whether_either_player_blocked_the_other() {
942
+ let (got, name, body) = exchange(json!({ "blocked": true }), |c| c.players().blocked(42, u64::MAX));
943
+ assert!(got.unwrap());
944
+ assert_eq!((name.as_str(), body), ("players.blocked", json!({ "player": "42", "other": "18446744073709551615" })));
945
+ let (got, ..) = exchange(json!({ "blocked": false }), |c| c.players().blocked(42, 43));
946
+ assert!(!got.unwrap());
947
+ let (got, ..) = exchange(json!({}), |c| c.players().blocked(42, 43));
948
+ assert_eq!(got.unwrap_err().status, Status::Internal, "a reply without the answer");
949
+ }
950
+
951
+ #[test]
952
+ fn a_safety_report_sends_what_the_game_api_reads_and_its_id_comes_back() {
953
+ let report = SafetyReport {
954
+ reporter: 42,
955
+ reported: u64::MAX,
956
+ reported_actor: Some("0123456789abcdef0123456789abcdef".into()),
957
+ reason: ReportReason::SexualContent,
958
+ details: "asked for pictures".into(),
959
+ occurred_at_ms: 951_782_400_123,
960
+ };
961
+ let (got, name, body) = exchange(json!({ "report_id": "77" }), |c| c.safety().report(&report));
962
+ assert_eq!(got.unwrap(), FiledReport { report_id: 77 });
963
+ assert_eq!(name, "safety.report");
964
+ assert_eq!(
965
+ body,
966
+ json!({ "reporter": "42", "reported": "18446744073709551615", "reported_actor": "0123456789abcdef0123456789abcdef",
967
+ "reason": "sexual_content", "details": "asked for pictures", "occurred_at": "2000-02-29T00:00:00.123Z" })
968
+ );
969
+ let no_actor = SafetyReport { reported_actor: None, reason: ReportReason::ChildSafety, ..report.clone() };
970
+ let (got, _, body) = exchange(json!({ "report_id": 78 }), |c| c.safety().report(&no_actor));
971
+ assert_eq!(got.unwrap().report_id, 78);
972
+ assert!(body.get("reported_actor").is_none(), "left out when there is none");
973
+ assert_eq!(body["reason"], "child_safety");
974
+ let (got, ..) = exchange(json!({ "reportId": "79" }), |c| c.safety().report(&no_actor));
975
+ assert_eq!(got.unwrap().report_id, 79);
976
+ for (reason, wire) in [
977
+ (ReportReason::Harassment, "harassment"),
978
+ (ReportReason::Hate, "hate"),
979
+ (ReportReason::SelfHarm, "self_harm"),
980
+ (ReportReason::Cheating, "cheating"),
981
+ (ReportReason::Other, "other"),
982
+ ] {
983
+ assert_eq!(serde_json::to_value(reason).unwrap(), wire);
984
+ }
985
+
986
+ let at_cap = SafetyReport { details: "\u{1F600}".repeat(MAX_REPORT_DETAILS / 2), ..no_actor.clone() };
987
+ let (got, ..) = exchange(json!({ "report_id": "80" }), |c| c.safety().report(&at_cap));
988
+ assert!(got.is_ok(), "the cap itself is allowed");
989
+ let over = SafetyReport { details: format!("{}a", "\u{1F600}".repeat(MAX_REPORT_DETAILS / 2)), ..no_actor };
990
+ assert_eq!(refused_here(|c| c.safety().report(&over)).status, Status::BadRequest, "counted as the game API counts");
991
+ }
992
+
993
+ #[test]
994
+ fn writes_times_as_the_game_api_does() {
995
+ assert_eq!(rfc3339(1_767_225_600_000), "2026-01-01T00:00:00.000Z");
996
+ assert_eq!(rfc3339(951_782_400_123), "2000-02-29T00:00:00.123Z");
997
+ assert_eq!(rfc3339(0), "1970-01-01T00:00:00.000Z");
998
+ }
999
+
779
1000
  #[test]
780
1001
  fn voxels_are_signed_and_name_their_author_and_time() {
781
1002
  let reply = json!({ "voxels": [
@@ -298,12 +298,27 @@ impl<T: Hub> HubNode<T> {
298
298
  };
299
299
  self.0.on_session(ctx, p, session)?
300
300
  }
301
+ (ERASE, Caller::Platform) => self.0.on_player_erased(ctx, decode::<PlayerEvent>(call.payload)?.player)?,
302
+ (EXPORT, Caller::Platform) => {
303
+ let held = self.0.on_player_export(ctx, decode::<PlayerEvent>(call.payload)?.player)?;
304
+ let json = serde_json::to_vec(&held).map_err(|e| Error(format!("export: {e}")))?;
305
+ if json.len() > crate::limits::MAX_EXPORT_BYTES {
306
+ return Err(Error(format!("the export of {} bytes exceeds {}", json.len(), crate::limits::MAX_EXPORT_BYTES)));
307
+ }
308
+ return Ok(json);
309
+ }
301
310
  (m, c) => return Err(Error(format!("no platform event `{m}` from {c:?}"))),
302
311
  }
303
312
  Ok(Vec::new())
304
313
  }
305
314
  }
306
315
 
316
+ /// The payload of `$erase` and `$export`.
317
+ #[derive(Serialize, Deserialize)]
318
+ pub(crate) struct PlayerEvent {
319
+ pub player: u64,
320
+ }
321
+
307
322
  /// The platform's reserved method names: its events.
308
323
  pub(crate) mod ckx_platform {
309
324
  pub const TIMER: &str = "$timer";
@@ -313,6 +328,8 @@ pub(crate) mod ckx_platform {
313
328
  pub const SESSION: &str = "$session";
314
329
  pub const SESSION_JOINED: u8 = 1;
315
330
  pub const SESSION_LEFT: u8 = 0;
331
+ pub const ERASE: &str = "$erase";
332
+ pub const EXPORT: &str = "$export";
316
333
  }
317
334
 
318
335
  impl<T: Hub> Node for HubNode<T> {
@@ -2,8 +2,8 @@
2
2
  //!
3
3
  //! [`TestHub`] and [`TestSpoke`] run a node on the test's thread the way a sandbox runs it:
4
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
5
+ //! clock the test moves, topics it watches, presence, sessions, a mod's world events, and a
6
+ //! player's erasure and export); and a snapshot and a start from it. The platform's side is a [`FakePlatform`]. It keeps what the
7
7
  //! node logged, published, sent and emitted; holds each call to the sandbox's bounds
8
8
  //! ([`crate::limits`]); answers calls to other types and node API operations with what the test
9
9
  //! scripts; and serves a mod its [`FakeGrid`] under the node API's rules for mods.
@@ -70,7 +70,7 @@ use serde::de::DeserializeOwned;
70
70
  use serde_json::{Value, json};
71
71
 
72
72
  use crate::abi::native::{self, Host};
73
- use crate::node::{Actor, Chunk, ChunkPos, Voxel};
73
+ use crate::node::{Actor, Chunk, ChunkPos, Voxel, rfc3339};
74
74
  use crate::rt::{HubNode, Node, SpokeNode, ckx_platform as platform};
75
75
  use crate::{Call, CallError, Caller, Ctx, Error, Hub, Kind, Level, Presence, Session, Spoke, Status, WorldEvent, decode, encode, limits};
76
76
 
@@ -431,7 +431,7 @@ impl FakePlatform {
431
431
  }
432
432
  match caller {
433
433
  Caller::Platform => {
434
- Err(bad_request("the platform calls a node only with its events: advance, topic, presence, session, world"))
434
+ Err(bad_request("the platform calls a node only with its events: advance, topic, presence, session, world, erase, export"))
435
435
  }
436
436
  Caller::Instance(_) if self.state.borrow().is_mod() => Err(denied("only players call mods")),
437
437
  _ => Ok(()),
@@ -696,6 +696,26 @@ impl<T: Hub> TestHub<T> {
696
696
  self.event(Caller::Platform, platform::PRESENCE, &payload)
697
697
  }
698
698
 
699
+ /// `player`'s account being erased, as the platform tells every hub: `$erase`, then a
700
+ /// snapshot, which the platform stores before it counts the hub done. A hub without the hook
701
+ /// answers `AppError` "not implemented: …".
702
+ pub fn erase(&mut self, player: u64) -> Result<(), CallError> {
703
+ let payload = player_payload(player).map_err(|Error(m)| bad_request(m))?;
704
+ self.event(Caller::Platform, platform::ERASE, &payload)?;
705
+ self.persist().map(|_| ())
706
+ }
707
+
708
+ /// What the hub holds about `player`, as the platform asks it for their data export:
709
+ /// `Value::Null` when it holds nothing. A hub without the hook answers `AppError` "not
710
+ /// implemented: …".
711
+ pub fn export(&mut self, player: u64) -> Result<Value, CallError> {
712
+ let payload = player_payload(player).map_err(|Error(m)| bad_request(m))?;
713
+ let node = self.node.as_mut().expect("the hub runs");
714
+ let json = self.platform.handle(node, Caller::Platform, platform::EXPORT, &payload)?;
715
+ serde_json::from_slice(&json)
716
+ .map_err(|e| CallError { status: Status::Internal, message: format!("an export that is not JSON: {e}") })
717
+ }
718
+
699
719
  /// `node_type`/`key` publishing `payload` on `topic`. A hub hears only the topics it
700
720
  /// watches.
701
721
  pub fn topic(&mut self, node_type: &str, key: &str, topic: &str, payload: &[u8]) -> Result<(), CallError> {
@@ -818,6 +838,12 @@ pub fn world_payload(events: &[WorldEvent]) -> crate::Result<Vec<u8>> {
818
838
  encode(&Batch { events })
819
839
  }
820
840
 
841
+ /// The payload of the `$erase` and `$export` calls the platform makes about `player`: what
842
+ /// [`TestHub::erase`] and [`TestHub::export`] hand a hub.
843
+ pub fn player_payload(player: u64) -> crate::Result<Vec<u8>> {
844
+ encode(&crate::rt::PlayerEvent { player })
845
+ }
846
+
821
847
  // ---- the node API, as it serves a mod ----
822
848
 
823
849
  /// The node API operations a mod may call.
@@ -825,12 +851,13 @@ const MOD_OPS: [&str; 7] =
825
851
  ["world.chunk", "world.voxels", "world.actors", "world.actors_radius", "world.set_voxels", "grids.get", "grids.check_permission"];
826
852
 
827
853
  /// Each node API operation and the scope it needs.
828
- const OP_SCOPES: [(&str, &str); 27] = [
854
+ const OP_SCOPES: [(&str, &str); 29] = [
829
855
  ("players.get", "players.read"),
830
856
  ("players.features", "players.read"),
831
857
  ("players.check_permission", "players.read"),
832
858
  ("players.state", "players.read"),
833
859
  ("players.avatar_state", "players.read"),
860
+ ("players.blocked", "players.read"),
834
861
  ("players.set_state", "players.write"),
835
862
  ("world.chunk", "world.read"),
836
863
  ("world.voxels", "world.read"),
@@ -853,6 +880,7 @@ const OP_SCOPES: [(&str, &str); 27] = [
853
880
  ("access.revoke", "access.write"),
854
881
  ("access.suspend", "access.write"),
855
882
  ("access.unsuspend", "access.write"),
883
+ ("safety.report", "safety.write"),
856
884
  ];
857
885
 
858
886
  /// Rows the node API returns at most: actors in one chunk, in a radius, voxels in a chunk.
@@ -954,21 +982,6 @@ fn b64(bytes: &[u8]) -> String {
954
982
  B64.encode(bytes)
955
983
  }
956
984
 
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
985
  type Pos = (i64, i64, i64);
973
986
 
974
987
  fn key(p: ChunkPos) -> Pos {
@@ -1424,6 +1437,85 @@ mod tests {
1424
1437
  assert_eq!(hub.call(Caller::Player(1), "nope", &[]).unwrap_err().status, Status::AppError);
1425
1438
  }
1426
1439
 
1440
+ /// Scores by player: what the platform's erasure and export reach.
1441
+ #[derive(Default, Serialize, Deserialize)]
1442
+ struct Keeper {
1443
+ scores: std::collections::BTreeMap<u64, i64>,
1444
+ }
1445
+
1446
+ impl Hub for Keeper {
1447
+ fn spawn(_ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
1448
+ Ok(Self::default())
1449
+ }
1450
+
1451
+ fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
1452
+ decode(snapshot)
1453
+ }
1454
+
1455
+ fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
1456
+ encode(self)
1457
+ }
1458
+
1459
+ fn handle(&mut self, _ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
1460
+ match call.method {
1461
+ "score" => {
1462
+ *self.scores.entry(call.player()?).or_default() += call.decode::<i64>()?;
1463
+ Ok(Vec::new())
1464
+ }
1465
+ "flood" => {
1466
+ self.scores.extend((0..60_000).map(|p| (p, i64::MAX)));
1467
+ Ok(Vec::new())
1468
+ }
1469
+ other => Err(Error::unknown_method(other)),
1470
+ }
1471
+ }
1472
+
1473
+ fn on_player_erased(&mut self, _ctx: &Ctx, player: u64) -> Result<()> {
1474
+ self.scores.remove(&player);
1475
+ Ok(())
1476
+ }
1477
+
1478
+ fn on_player_export(&mut self, _ctx: &Ctx, player: u64) -> Result<Value> {
1479
+ if player == 7 {
1480
+ return Ok(json!({ "everyone": self.scores }));
1481
+ }
1482
+ Ok(self.scores.get(&player).map_or(Value::Null, |s| json!({ "score": s })))
1483
+ }
1484
+ }
1485
+
1486
+ #[test]
1487
+ fn a_hub_erases_and_exports_a_player_as_the_platform_asks() {
1488
+ let mut hub = TestHub::<Keeper>::spawn("board", "a").unwrap();
1489
+ hub.call(Caller::Player(42), "score", &encode(&5).unwrap()).unwrap();
1490
+ hub.call(Caller::Player(43), "score", &encode(&9).unwrap()).unwrap();
1491
+ assert_eq!(hub.export(42).unwrap(), json!({ "score": 5 }));
1492
+ assert_eq!(hub.export(44).unwrap(), Value::Null, "nothing held");
1493
+ hub.erase(42).unwrap();
1494
+ assert_eq!(hub.export(42).unwrap(), Value::Null);
1495
+ hub.restart().unwrap();
1496
+ assert_eq!(hub.hub().scores.keys().copied().collect::<Vec<_>>(), vec![43], "the erasure is in the snapshot");
1497
+ for method in ["$erase", "$export"] {
1498
+ for caller in [Caller::Player(42), Caller::Developer(1), Caller::Instance(9)] {
1499
+ let e = hub.call(caller, method, &player_payload(43).unwrap()).unwrap_err();
1500
+ assert_eq!(e.status, Status::Denied, "{method} from {caller:?}");
1501
+ }
1502
+ }
1503
+ assert_eq!(hub.hub().scores.len(), 1);
1504
+
1505
+ hub.call(Caller::Player(1), "flood", &[]).unwrap();
1506
+ let e = hub.export(7).unwrap_err();
1507
+ assert!(e.status == Status::AppError && e.message.contains("exceeds"), "{e}");
1508
+ }
1509
+
1510
+ #[test]
1511
+ fn a_hub_without_the_hooks_answers_not_implemented() {
1512
+ let mut hub = TestHub::<Ticker>::spawn("ticker", "a").unwrap();
1513
+ for e in [hub.erase(42).unwrap_err(), hub.export(42).unwrap_err()] {
1514
+ assert_eq!(e.status, Status::AppError);
1515
+ assert!(e.message.starts_with("not implemented: `on_player_e"), "{e}");
1516
+ }
1517
+ }
1518
+
1427
1519
  #[test]
1428
1520
  fn one_node_runs_on_a_thread_at_a_time() {
1429
1521
  let hub = TestHub::<Ticker>::spawn("ticker", "a").unwrap();
@@ -1432,11 +1524,4 @@ mod tests {
1432
1524
  drop(hub);
1433
1525
  TestHub::<Ticker>::spawn("ticker", "b").unwrap();
1434
1526
  }
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
1527
  }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "crowdy-client-sdk"
3
- version = "0.1.0"
3
+ version = "0.1.0-dev.1"
4
4
  edition = "2021"
5
5
  description = "Guest SDK for the CLIENT half of a ck-exec mod: browser WASM (ck ABI v0 + the client host calls)."
6
6
  license = "MIT OR Apache-2.0"
@@ -13,6 +13,7 @@ exports.EXEC_ARTIFACT_MAX_BYTES = exports.EXEC_ABI_EXPORTS = exports.EXEC_ABI_IM
13
13
  exports.validateExecPath = validateExecPath;
14
14
  exports.validateExecManifest = validateExecManifest;
15
15
  exports.validateExecCrates = validateExecCrates;
16
+ exports.execArtifactName = execArtifactName;
16
17
  exports.rewriteExecManifest = rewriteExecManifest;
17
18
  exports.clientTickIntervalMs = clientTickIntervalMs;
18
19
  exports.validateExecArtifact = validateExecArtifact;
@@ -259,6 +260,39 @@ function validateExecCrates(crates, kind = 'exec') {
259
260
  if (total > exports.EXEC_MAX_SOURCE_BYTES)
260
261
  fail(`a build's sources are at most ${exports.EXEC_MAX_SOURCE_BYTES} bytes`);
261
262
  }
263
+ /**
264
+ * The file name cargo gives a validated crate's module: its `[lib] name`, else its package
265
+ * name with `-` as `_`, then `.wasm`. A build's crates share one target directory, so the
266
+ * module has to be found by name rather than as the only `.wasm` there.
267
+ */
268
+ function execArtifactName(manifest) {
269
+ let section = '';
270
+ let pkg = null;
271
+ let lib = null;
272
+ for (const raw of manifest.split(/\r?\n/)) {
273
+ const line = stripComment(raw).trim();
274
+ const header = line.match(/^\[([^\]]+)\]$/);
275
+ if (header) {
276
+ section = header[1].trim();
277
+ continue;
278
+ }
279
+ const kv = line.match(/^name\s*=\s*"([^"]+)"$/);
280
+ if (!kv)
281
+ continue;
282
+ if (section === 'package')
283
+ pkg = kv[1];
284
+ if (section === 'lib')
285
+ lib = kv[1];
286
+ }
287
+ const name = lib ?? pkg;
288
+ if (!name)
289
+ fail('Cargo.toml has no [package] name');
290
+ // The name becomes a file name under the build's target directory: a cargo identifier and
291
+ // nothing a path could be made of, before cargo itself would refuse it.
292
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name))
293
+ fail(`Cargo.toml name '${name}' is not a crate name`);
294
+ return `${name.replace(/-/g, '_')}.wasm`;
295
+ }
262
296
  /**
263
297
  * Points the kind's SDK at the toolchain's pinned copy and appends the platform release
264
298
  * profile (a crate may not declare its own; the validator refuses [profile]).
package/kit.pin CHANGED
@@ -1,9 +1,9 @@
1
1
  # The platform toolchain release this kit was copied from (sync-kit.sh in the platform's
2
2
  # repository). The paths in `copied` are byte-identical to it: change them there, never here.
3
- toolchain_version=v1.15.0
4
- toolchain_commit=6eb7628c97d18f14a59e36f692f15530abd56cbe
5
- sdk_commit=c682f9378d742b7420a3bf0b49309cc27fba85bb
6
- ckx_sdk_version=0.12.1
3
+ toolchain_version=v1.16.0
4
+ toolchain_commit=c3cb02186b34ad535dd30c333ab4d9b116e5866d
5
+ sdk_commit=27d5774d7b9d3b3c28928ad1a4d27902ac5130e1
6
+ ckx_sdk_version=0.13.0
7
7
  crowdy_client_sdk_version=0.1.0
8
8
  rust_version=1.97.1
9
9
  copied=crates/ckx-sdk/src crates/crowdy-client-sdk/src tools/instrument/Cargo.toml tools/instrument/Cargo.lock tools/instrument/src checks/src/build/exec-source.ts checks/src/build/exec-source.spec.ts checks/src/build/rust-source-guard.ts checks/src/build/rust-source-guard.spec.ts checks/src/build/wasm-inspect.ts checks/src/build/wasm-inspect.spec.ts checks/src/build/client-capabilities.ts checks/src/build/client-capabilities.spec.ts checks/src/test-support/wasm-module.ts checks/compute-toolchain/host-catalog.json starters rust-toolchain.toml vendor.lock
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowdedkingdoms/ckx-kit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0-dev.1",
4
4
  "description": "Build, check and test Crowded Kingdoms ck-exec mods, hubs and spokes on your machine, as the platform builds them",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "homepage": "https://docs.crowdedkingdoms.com/exec/intro",
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,5 +9,5 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,5 +9,5 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,5 +9,5 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.12.1"
12
+ ckx-sdk = "0.13.0"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"