@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,395 @@
1
+ //! One game session, keyed by its id, from lobby to result. Players join the lobby; the host
2
+ //! (the first to join, then the next connected player if the host goes) starts once everyone
3
+ //! is ready; turns go round with a time limit, and a player who runs out of time is skipped;
4
+ //! the hub decides each move's points and the winner. A matchmaker can reserve the session for
5
+ //! its players first. Replaces the `match-engine` template.
6
+ //!
7
+ //! Endpoints (players): `join`, `leave`, `ready { ready }`, `start` (host), `act { points }`
8
+ //! (the player whose turn it is), `state` (anyone). `configure { .. }` (the host in the lobby,
9
+ //! or a developer) and `reserve { players }` (another instance, or a developer). Every change
10
+ //! is pushed on the `session` topic.
11
+ //!
12
+ //! [`points_for`] is the game's rule; replace it with yours.
13
+
14
+ use std::collections::{BTreeMap, BTreeSet, VecDeque};
15
+
16
+ use ckx_sdk::prelude::*;
17
+
18
+ const LOG_MOVES: usize = 20;
19
+
20
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
21
+ #[serde(default)]
22
+ pub struct Config {
23
+ pub min_players: usize,
24
+ pub max_players: usize,
25
+ pub turn_ms: u64,
26
+ pub target_score: i64,
27
+ pub max_rounds: u32,
28
+ /// A lobby nobody joins closes after this long.
29
+ pub lobby_idle_ms: u64,
30
+ }
31
+
32
+ impl Default for Config {
33
+ fn default() -> Self {
34
+ Self { min_players: 2, max_players: 8, turn_ms: 30_000, target_score: 10, max_rounds: 20, lobby_idle_ms: 600_000 }
35
+ }
36
+ }
37
+
38
+ #[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
39
+ #[serde(rename_all = "snake_case")]
40
+ pub enum Phase {
41
+ #[default]
42
+ Lobby,
43
+ Playing,
44
+ Finished,
45
+ }
46
+
47
+ /// A move as the client sends it. Replace with your game's action.
48
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
49
+ pub struct Action {
50
+ pub points: i64,
51
+ }
52
+
53
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
54
+ pub struct Move {
55
+ pub player: u64,
56
+ pub round: u32,
57
+ /// None when the player ran out of time.
58
+ pub points: Option<i64>,
59
+ }
60
+
61
+ /// The game's rule for what a move scores. The starter's: 0 to 3 points, as asked.
62
+ pub fn points_for(action: &Action) -> Result<i64> {
63
+ if (0..=3).contains(&action.points) { Ok(action.points) } else { Err(Error::new("a move scores 0 to 3 points")) }
64
+ }
65
+
66
+ #[derive(Deserialize)]
67
+ struct Ready {
68
+ ready: bool,
69
+ }
70
+
71
+ #[derive(Deserialize)]
72
+ struct Reserve {
73
+ players: Vec<u64>,
74
+ }
75
+
76
+ #[derive(Serialize, Deserialize, Default, Debug)]
77
+ pub struct GameSession {
78
+ pub config: Config,
79
+ pub phase: Phase,
80
+ /// Only these players may join, when set (a matchmaker's reservation).
81
+ pub reserved: Vec<u64>,
82
+ pub players: Vec<u64>,
83
+ pub ready: BTreeSet<u64>,
84
+ pub connected: BTreeSet<u64>,
85
+ pub host: Option<u64>,
86
+ pub turn: usize,
87
+ pub round: u32,
88
+ pub scores: BTreeMap<u64, i64>,
89
+ pub winner: Option<u64>,
90
+ pub moves: VecDeque<Move>,
91
+ }
92
+
93
+ impl GameSession {
94
+ fn now_turn(&self) -> Option<u64> {
95
+ (self.phase == Phase::Playing).then(|| self.players.get(self.turn).copied()).flatten()
96
+ }
97
+
98
+ pub fn join(&mut self, player: u64) -> Result<()> {
99
+ if self.players.contains(&player) {
100
+ return Ok(());
101
+ }
102
+ if self.phase != Phase::Lobby {
103
+ return Err(Error::new("the session has started"));
104
+ }
105
+ if !self.reserved.is_empty() && !self.reserved.contains(&player) {
106
+ return Err(Error::new("this session is reserved for other players"));
107
+ }
108
+ if self.players.len() >= self.config.max_players {
109
+ return Err(Error::new("the session is full"));
110
+ }
111
+ self.players.push(player);
112
+ self.scores.insert(player, 0);
113
+ self.host.get_or_insert(player);
114
+ Ok(())
115
+ }
116
+
117
+ /// Returns whether the turn timer needs re-arming.
118
+ pub fn leave(&mut self, player: u64) -> bool {
119
+ let Some(i) = self.players.iter().position(|p| *p == player) else { return false };
120
+ let was_turn = self.now_turn() == Some(player);
121
+ self.players.remove(i);
122
+ self.ready.remove(&player);
123
+ if self.phase == Phase::Lobby {
124
+ self.scores.remove(&player);
125
+ }
126
+ if self.host == Some(player) {
127
+ self.host = self.players.iter().find(|p| self.connected.contains(p)).or(self.players.first()).copied();
128
+ }
129
+ if self.phase != Phase::Playing {
130
+ return false;
131
+ }
132
+ if self.players.len() < self.config.min_players {
133
+ self.finish();
134
+ return false;
135
+ }
136
+ if i < self.turn {
137
+ self.turn -= 1;
138
+ }
139
+ if self.turn >= self.players.len() {
140
+ self.turn = 0;
141
+ self.round += 1;
142
+ }
143
+ was_turn
144
+ }
145
+
146
+ pub fn start(&mut self, player: u64) -> Result<()> {
147
+ if self.phase != Phase::Lobby {
148
+ return Err(Error::new("the session has started"));
149
+ }
150
+ if self.host != Some(player) {
151
+ return Err(Error::new("only the host starts the session"));
152
+ }
153
+ if self.players.len() < self.config.min_players {
154
+ return Err(Error::new(format!("at least {} players are needed", self.config.min_players)));
155
+ }
156
+ if self.players.iter().any(|p| !self.ready.contains(p)) {
157
+ return Err(Error::new("everyone must be ready"));
158
+ }
159
+ self.phase = Phase::Playing;
160
+ self.turn = 0;
161
+ self.round = 1;
162
+ Ok(())
163
+ }
164
+
165
+ /// The current player's move, or a timeout (`action` None). Returns whether play goes on.
166
+ pub fn play(&mut self, player: u64, action: Option<&Action>) -> Result<bool> {
167
+ if self.now_turn() != Some(player) {
168
+ return Err(Error::new("it is not your turn"));
169
+ }
170
+ let points = action.map(points_for).transpose()?;
171
+ let score = self.scores.entry(player).or_insert(0);
172
+ *score += points.unwrap_or(0);
173
+ let reached = *score >= self.config.target_score;
174
+ self.moves.push_back(Move { player, round: self.round, points });
175
+ while self.moves.len() > LOG_MOVES {
176
+ self.moves.pop_front();
177
+ }
178
+ self.turn += 1;
179
+ if self.turn >= self.players.len() {
180
+ self.turn = 0;
181
+ self.round += 1;
182
+ }
183
+ if reached || self.round > self.config.max_rounds {
184
+ self.finish();
185
+ return Ok(false);
186
+ }
187
+ Ok(true)
188
+ }
189
+
190
+ fn finish(&mut self) {
191
+ self.phase = Phase::Finished;
192
+ // The highest score; the earlier player wins a tie.
193
+ self.winner = self.players.iter().copied().max_by_key(|p| (self.scores.get(p).copied().unwrap_or(0), std::cmp::Reverse(*p)));
194
+ }
195
+
196
+ fn arm_turn(&self, ctx: &Ctx) -> Result<()> {
197
+ if self.phase == Phase::Playing {
198
+ ctx.timer_after("turn", self.config.turn_ms)
199
+ } else {
200
+ ctx.cancel_timer("turn");
201
+ Ok(())
202
+ }
203
+ }
204
+ }
205
+
206
+ impl Hub for GameSession {
207
+ fn spawn(ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
208
+ let s = Self::default();
209
+ ctx.timer_after("idle", s.config.lobby_idle_ms)?;
210
+ Ok(s)
211
+ }
212
+
213
+ fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
214
+ decode(snapshot)
215
+ }
216
+
217
+ fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
218
+ encode(self)
219
+ }
220
+
221
+ fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
222
+ let out = match call.method {
223
+ "state" => return encode(&*self),
224
+ "join" => {
225
+ self.join(call.player()?)?;
226
+ ctx.cancel_timer("idle");
227
+ encode(&true)
228
+ }
229
+ "leave" => {
230
+ if self.leave(call.player()?) {
231
+ self.arm_turn(ctx)?;
232
+ }
233
+ encode(&true)
234
+ }
235
+ "ready" => {
236
+ let player = call.player()?;
237
+ let req: Ready = call.decode()?;
238
+ if self.phase != Phase::Lobby || !self.players.contains(&player) {
239
+ return Err(Error::new("join the lobby first"));
240
+ }
241
+ if req.ready {
242
+ self.ready.insert(player);
243
+ } else {
244
+ self.ready.remove(&player);
245
+ }
246
+ encode(&req.ready)
247
+ }
248
+ "start" => {
249
+ self.start(call.player()?)?;
250
+ self.arm_turn(ctx)?;
251
+ encode(&true)
252
+ }
253
+ "act" => {
254
+ let player = call.player()?;
255
+ let action: Action = call.decode()?;
256
+ let more = self.play(player, Some(&action))?;
257
+ self.arm_turn(ctx)?;
258
+ encode(&more)
259
+ }
260
+ "configure" => {
261
+ let allowed = match call.caller {
262
+ Caller::Developer(_) => true,
263
+ Caller::Player(p) => self.host == Some(p),
264
+ _ => false,
265
+ };
266
+ if !allowed || self.phase != Phase::Lobby {
267
+ return Err(Error::new("the host configures the session in the lobby"));
268
+ }
269
+ let config: Config = call.decode()?;
270
+ if config.min_players < 1 || config.max_players < config.min_players || config.max_players > 64 {
271
+ return Err(Error::new("1 <= min_players <= max_players <= 64"));
272
+ }
273
+ if config.turn_ms < 1_000 {
274
+ return Err(Error::new("turn_ms is at least 1000"));
275
+ }
276
+ self.config = config;
277
+ encode(&true)
278
+ }
279
+ "reserve" => {
280
+ if !matches!(call.caller, Caller::Instance(_) | Caller::Developer(_)) {
281
+ return Err(Error::new("reserve is for the matchmaker"));
282
+ }
283
+ let req: Reserve = call.decode()?;
284
+ if self.phase != Phase::Lobby || !self.players.is_empty() {
285
+ return Err(Error::new("the session is already in use"));
286
+ }
287
+ if req.players.is_empty() || req.players.len() > self.config.max_players {
288
+ return Err(Error::new("reserve 1 to max_players players"));
289
+ }
290
+ self.reserved = req.players;
291
+ encode(&true)
292
+ }
293
+ other => return Err(Error::unknown_method(other)),
294
+ };
295
+ ctx.publish("session", &encode(&*self)?);
296
+ ctx.persist_now();
297
+ out
298
+ }
299
+
300
+ fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
301
+ match timer {
302
+ "turn" => {
303
+ if let Some(player) = self.now_turn() {
304
+ self.play(player, None)?;
305
+ self.arm_turn(ctx)?;
306
+ }
307
+ }
308
+ "idle" if self.phase == Phase::Lobby && self.players.is_empty() => self.phase = Phase::Finished,
309
+ _ => return Ok(()),
310
+ }
311
+ ctx.publish("session", &encode(&*self)?);
312
+ Ok(())
313
+ }
314
+
315
+ fn on_session(&mut self, _ctx: &Ctx, player: u64, session: Session) -> Result<()> {
316
+ match session {
317
+ Session::Joined => {
318
+ self.connected.insert(player);
319
+ }
320
+ Session::Left => {
321
+ self.connected.remove(&player);
322
+ if self.host == Some(player)
323
+ && let Some(next) = self.players.iter().find(|p| self.connected.contains(p))
324
+ {
325
+ self.host = Some(*next);
326
+ }
327
+ }
328
+ }
329
+ Ok(())
330
+ }
331
+ }
332
+
333
+ ckx_sdk::export_hub!(GameSession);
334
+
335
+ #[cfg(test)]
336
+ mod tests {
337
+ use super::*;
338
+
339
+ fn lobby(players: &[u64]) -> GameSession {
340
+ let mut s = GameSession::default();
341
+ for p in players {
342
+ s.join(*p).unwrap();
343
+ s.ready.insert(*p);
344
+ }
345
+ s
346
+ }
347
+
348
+ #[test]
349
+ fn the_first_to_join_hosts_and_only_the_host_starts_when_all_are_ready() {
350
+ let mut s = lobby(&[1, 2]);
351
+ assert_eq!(s.host, Some(1));
352
+ s.ready.remove(&2);
353
+ assert!(s.start(1).is_err(), "not everyone is ready");
354
+ s.ready.insert(2);
355
+ assert!(s.start(2).is_err(), "not the host");
356
+ s.start(1).unwrap();
357
+ assert_eq!((s.phase, s.round, s.now_turn()), (Phase::Playing, 1, Some(1)));
358
+ assert!(s.join(3).is_err(), "started");
359
+ }
360
+
361
+ #[test]
362
+ fn turns_go_round_timeouts_skip_and_the_target_wins() {
363
+ let mut s = lobby(&[1, 2]);
364
+ s.config.target_score = 4;
365
+ s.start(1).unwrap();
366
+ assert!(s.play(2, Some(&Action { points: 1 })).is_err(), "not your turn");
367
+ assert!(s.play(1, Some(&Action { points: 9 })).is_err(), "the rule refuses it");
368
+ assert!(s.play(1, Some(&Action { points: 3 })).unwrap());
369
+ assert!(s.play(2, None).unwrap(), "a timeout scores nothing");
370
+ assert_eq!(s.round, 2);
371
+ assert!(!s.play(1, Some(&Action { points: 1 })).unwrap());
372
+ assert_eq!((s.phase, s.winner), (Phase::Finished, Some(1)));
373
+ assert_eq!(s.moves.iter().filter(|m| m.points.is_none()).count(), 1);
374
+ }
375
+
376
+ #[test]
377
+ fn a_reserved_session_admits_only_its_players() {
378
+ let mut s = GameSession { reserved: vec![1, 2], ..Default::default() };
379
+ assert!(s.join(3).is_err());
380
+ s.join(2).unwrap();
381
+ assert_eq!(s.host, Some(2));
382
+ }
383
+
384
+ #[test]
385
+ fn leaving_hands_on_the_host_and_the_turn_and_too_few_players_ends_it() {
386
+ let mut s = lobby(&[1, 2, 3]);
387
+ s.connected.extend([2, 3]);
388
+ s.start(1).unwrap();
389
+ assert!(s.leave(1), "it was their turn");
390
+ assert_eq!((s.host, s.now_turn()), (Some(2), Some(2)));
391
+ s.scores.insert(3, 5);
392
+ assert!(!s.leave(2));
393
+ assert_eq!((s.phase, s.winner), (Phase::Finished, Some(3)));
394
+ }
395
+ }
@@ -0,0 +1,14 @@
1
+ [package]
2
+ name = "world-tick"
3
+ description = "Starter: the world's clock, weather, resource nodes and liveops events (root hub)"
4
+ version = "0.1.0"
5
+ edition = "2024"
6
+ publish = false
7
+
8
+ [lib]
9
+ crate-type = ["cdylib"]
10
+
11
+ [dependencies]
12
+ ckx-sdk = "0.8.0"
13
+ serde = { version = "1", features = ["derive"] }
14
+ serde_json = "1"