@crowdedkingdoms/ckx-kit 0.0.0-stage → 0.1.1-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,362 @@
1
+ //! A matchmaking queue, keyed by its name (`ranked`, `casual`, ...). Players join with a
2
+ //! rating; the window of ratings they accept widens the longer they wait; a match is proposed
3
+ //! when enough players fall in each other's windows, and every one of them must accept in
4
+ //! time. When all do, the queue reserves a `session` hub for them (the manifest lets this type
5
+ //! call `session`) and tells each player its key. Replaces the `matchmaking` template.
6
+ //!
7
+ //! Endpoints (players): `join { rating }`, `leave`, `accept`, `decline`, `status` (your own
8
+ //! place: queued, proposed or matched). `configure { .. }` (developers). The `queue` topic
9
+ //! carries how many are waiting, never who.
10
+
11
+ use std::collections::{BTreeMap, BTreeSet};
12
+
13
+ use ckx_sdk::prelude::*;
14
+
15
+ pub const TICK_MS: u64 = 1_000;
16
+ /// How long a player's match stays in `status` after it is made.
17
+ const MATCHED_KEEP_MS: u64 = 120_000;
18
+ const MAX_QUEUE: usize = 10_000;
19
+
20
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
21
+ #[serde(default)]
22
+ pub struct Config {
23
+ pub players_per_match: usize,
24
+ /// The rating difference a player accepts at first, and how it widens each second.
25
+ pub window: u32,
26
+ pub widen_per_sec: u32,
27
+ pub max_window: u32,
28
+ pub accept_ms: u64,
29
+ }
30
+
31
+ impl Default for Config {
32
+ fn default() -> Self {
33
+ Self { players_per_match: 2, window: 100, widen_per_sec: 10, max_window: 500, accept_ms: 15_000 }
34
+ }
35
+ }
36
+
37
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
38
+ pub struct Entry {
39
+ pub player: u64,
40
+ pub rating: u32,
41
+ pub since: u64,
42
+ }
43
+
44
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
45
+ pub struct Proposal {
46
+ pub id: u64,
47
+ pub entries: Vec<Entry>,
48
+ pub accepted: BTreeSet<u64>,
49
+ pub expires: u64,
50
+ }
51
+
52
+ impl Proposal {
53
+ fn has(&self, player: u64) -> bool {
54
+ self.entries.iter().any(|e| e.player == player)
55
+ }
56
+
57
+ pub fn players(&self) -> Vec<u64> {
58
+ self.entries.iter().map(|e| e.player).collect()
59
+ }
60
+ }
61
+
62
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
63
+ pub struct Matched {
64
+ pub session: String,
65
+ pub at: u64,
66
+ }
67
+
68
+ #[derive(Serialize, Deserialize, Debug, PartialEq)]
69
+ #[serde(tag = "state", rename_all = "snake_case")]
70
+ pub enum Place {
71
+ Idle,
72
+ Queued { rating: u32, waited_ms: u64, window: u32 },
73
+ Proposed { players: Vec<u64>, accepted: Vec<u64>, expires_in_ms: u64 },
74
+ Matched { session: String },
75
+ }
76
+
77
+ #[derive(Deserialize)]
78
+ struct Join {
79
+ rating: u32,
80
+ }
81
+
82
+ #[derive(Serialize)]
83
+ struct Reserve<'a> {
84
+ players: &'a [u64],
85
+ }
86
+
87
+ #[derive(Serialize, Deserialize, Default, Debug)]
88
+ pub struct Matchmaker {
89
+ pub config: Config,
90
+ pub queue: Vec<Entry>,
91
+ pub proposals: BTreeMap<u64, Proposal>,
92
+ pub matched: BTreeMap<u64, Matched>,
93
+ pub next_id: u64,
94
+ }
95
+
96
+ impl Matchmaker {
97
+ fn window(&self, e: &Entry, now: u64) -> u32 {
98
+ let waited = (now.saturating_sub(e.since) / 1_000) as u32;
99
+ self.config.window.saturating_add(self.config.widen_per_sec.saturating_mul(waited)).min(self.config.max_window)
100
+ }
101
+
102
+ fn proposal_of(&self, player: u64) -> Option<u64> {
103
+ self.proposals.values().find(|p| p.has(player)).map(|p| p.id)
104
+ }
105
+
106
+ pub fn join(&mut self, player: u64, rating: u32, now: u64) -> Result<()> {
107
+ if self.proposal_of(player).is_some() {
108
+ return Err(Error::new("you have a match to accept"));
109
+ }
110
+ self.matched.remove(&player);
111
+ if let Some(e) = self.queue.iter_mut().find(|e| e.player == player) {
112
+ e.rating = rating;
113
+ return Ok(());
114
+ }
115
+ if self.queue.len() >= MAX_QUEUE {
116
+ return Err(Error::new("the queue is full"));
117
+ }
118
+ self.queue.push(Entry { player, rating, since: now });
119
+ Ok(())
120
+ }
121
+
122
+ pub fn leave(&mut self, player: u64) {
123
+ self.queue.retain(|e| e.player != player);
124
+ if let Some(id) = self.proposal_of(player) {
125
+ self.decline(player, id);
126
+ }
127
+ }
128
+
129
+ /// Proposes matches from the queue, oldest entries first.
130
+ pub fn propose(&mut self, now: u64) -> Vec<u64> {
131
+ let n = self.config.players_per_match.max(1);
132
+ let mut waiting = std::mem::take(&mut self.queue);
133
+ waiting.sort_by_key(|e| e.since);
134
+ let mut made = Vec::new();
135
+ let mut i = 0;
136
+ while i < waiting.len() {
137
+ let a = waiting[i].clone();
138
+ let picked: Vec<usize> = std::iter::once(i)
139
+ .chain(
140
+ (i + 1..waiting.len())
141
+ .filter(|&j| {
142
+ let b = &waiting[j];
143
+ a.rating.abs_diff(b.rating) <= self.window(&a, now).min(self.window(b, now))
144
+ })
145
+ .take(n - 1),
146
+ )
147
+ .collect();
148
+ if picked.len() < n {
149
+ i += 1;
150
+ continue;
151
+ }
152
+ // Highest index first, so the lower ones stay valid; then back into queue order.
153
+ let mut entries: Vec<Entry> = picked.iter().rev().map(|&j| waiting.remove(j)).collect();
154
+ entries.reverse();
155
+ self.next_id += 1;
156
+ let id = self.next_id;
157
+ self.proposals.insert(id, Proposal { id, entries, accepted: BTreeSet::new(), expires: now + self.config.accept_ms });
158
+ made.push(id);
159
+ }
160
+ self.queue = waiting;
161
+ made
162
+ }
163
+
164
+ /// Returns the proposal when this acceptance completes it.
165
+ pub fn accept(&mut self, player: u64) -> Result<Option<Proposal>> {
166
+ let id = self.proposal_of(player).ok_or_else(|| Error::new("you have no match to accept"))?;
167
+ let p = self.proposals.get_mut(&id).expect("found above");
168
+ p.accepted.insert(player);
169
+ if p.accepted.len() < p.entries.len() {
170
+ return Ok(None);
171
+ }
172
+ Ok(self.proposals.remove(&id))
173
+ }
174
+
175
+ /// The decliner leaves the queue; the others go back, keeping their place.
176
+ pub fn decline(&mut self, player: u64, id: u64) {
177
+ if let Some(p) = self.proposals.remove(&id) {
178
+ self.queue.extend(p.entries.into_iter().filter(|e| e.player != player));
179
+ }
180
+ }
181
+
182
+ /// Proposals past their time: those who accepted go back, the others leave.
183
+ pub fn expire(&mut self, now: u64) {
184
+ let late: Vec<u64> = self.proposals.values().filter(|p| p.expires <= now).map(|p| p.id).collect();
185
+ for id in late {
186
+ if let Some(p) = self.proposals.remove(&id) {
187
+ self.queue.extend(p.entries.into_iter().filter(|e| p.accepted.contains(&e.player)));
188
+ }
189
+ }
190
+ self.matched.retain(|_, m| now.saturating_sub(m.at) < MATCHED_KEEP_MS);
191
+ }
192
+
193
+ pub fn status(&self, player: u64, now: u64) -> Place {
194
+ if let Some(e) = self.queue.iter().find(|e| e.player == player) {
195
+ return Place::Queued { rating: e.rating, waited_ms: now.saturating_sub(e.since), window: self.window(e, now) };
196
+ }
197
+ if let Some(p) = self.proposal_of(player).and_then(|id| self.proposals.get(&id)) {
198
+ return Place::Proposed {
199
+ players: p.players(),
200
+ accepted: p.accepted.iter().copied().collect(),
201
+ expires_in_ms: p.expires.saturating_sub(now),
202
+ };
203
+ }
204
+ match self.matched.get(&player) {
205
+ Some(m) => Place::Matched { session: m.session.clone() },
206
+ None => Place::Idle,
207
+ }
208
+ }
209
+
210
+ fn busy(&self) -> bool {
211
+ !self.queue.is_empty() || !self.proposals.is_empty()
212
+ }
213
+ }
214
+
215
+ impl Hub for Matchmaker {
216
+ fn spawn(_ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
217
+ Ok(Self::default())
218
+ }
219
+
220
+ fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
221
+ decode(snapshot)
222
+ }
223
+
224
+ fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
225
+ encode(self)
226
+ }
227
+
228
+ fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
229
+ let now = ctx.now_ms();
230
+ let out = match call.method {
231
+ "status" => return encode(&self.status(call.player()?, now)),
232
+ "join" => {
233
+ let req: Join = call.decode()?;
234
+ self.join(call.player()?, req.rating, now)?;
235
+ encode(&self.status(call.player()?, now))
236
+ }
237
+ "leave" => {
238
+ self.leave(call.player()?);
239
+ encode(&true)
240
+ }
241
+ "accept" => {
242
+ let player = call.player()?;
243
+ if let Some(p) = self.accept(player)? {
244
+ // The epoch rises on every placement, so a queue restarted without its
245
+ // snapshot never hands out a key an earlier match used.
246
+ let session = format!("{}-{}-{}", ctx.key, ctx.epoch, p.id);
247
+ let players = p.players();
248
+ match ctx.call_typed::<_, bool>("session", &session, "reserve", &Reserve { players: &players }) {
249
+ Ok(_) => {
250
+ for player in players {
251
+ self.matched.insert(player, Matched { session: session.clone(), at: now });
252
+ }
253
+ }
254
+ Err(e) => {
255
+ ctx.log(Level::Warn, &format!("reserving session {session}: {e}"));
256
+ self.queue.extend(p.entries);
257
+ }
258
+ }
259
+ }
260
+ encode(&self.status(player, now))
261
+ }
262
+ "decline" => {
263
+ let player = call.player()?;
264
+ if let Some(id) = self.proposal_of(player) {
265
+ self.decline(player, id);
266
+ }
267
+ encode(&true)
268
+ }
269
+ "configure" => {
270
+ call.developer()?;
271
+ let config: Config = call.decode()?;
272
+ if !(1..=64).contains(&config.players_per_match) || config.accept_ms < 1_000 {
273
+ return Err(Error::new("1 <= players_per_match <= 64, and accept_ms is at least 1000"));
274
+ }
275
+ self.config = config;
276
+ encode(&true)
277
+ }
278
+ other => return Err(Error::unknown_method(other)),
279
+ };
280
+ if self.busy() && !ctx.timers().iter().any(|(name, _)| name == "match") {
281
+ ctx.timer_every("match", TICK_MS)?;
282
+ }
283
+ out
284
+ }
285
+
286
+ fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
287
+ if timer != "match" {
288
+ return Ok(());
289
+ }
290
+ let now = ctx.now_ms();
291
+ self.expire(now);
292
+ self.propose(now);
293
+ ctx.publish("queue", &encode(&(self.queue.len(), self.proposals.len()))?);
294
+ if !self.busy() {
295
+ ctx.cancel_timer("match");
296
+ }
297
+ Ok(())
298
+ }
299
+ }
300
+
301
+ ckx_sdk::export_hub!(Matchmaker);
302
+
303
+ #[cfg(test)]
304
+ mod tests {
305
+ use super::*;
306
+
307
+ #[test]
308
+ fn close_ratings_match_at_once_and_far_ones_after_the_window_widens() {
309
+ let mut m = Matchmaker::default();
310
+ m.join(1, 1000, 0).unwrap();
311
+ m.join(2, 1050, 0).unwrap();
312
+ m.join(3, 1400, 0).unwrap();
313
+ let first = m.propose(0);
314
+ assert_eq!(first.len(), 1);
315
+ assert_eq!(m.proposals[&first[0]].players(), vec![1, 2]);
316
+ m.join(4, 1700, 0).unwrap();
317
+ assert!(m.propose(10_000).is_empty(), "300 apart, windows are 200");
318
+ assert_eq!(m.propose(20_000).len(), 1, "windows reach 300 after 20 s");
319
+ }
320
+
321
+ #[test]
322
+ fn everyone_must_accept_and_a_decline_requeues_the_others() {
323
+ let mut m = Matchmaker::default();
324
+ m.join(1, 1000, 0).unwrap();
325
+ m.join(2, 1000, 5).unwrap();
326
+ m.propose(10);
327
+ assert!(m.accept(1).unwrap().is_none());
328
+ assert!(matches!(m.status(2, 10), Place::Proposed { .. }));
329
+ let id = m.proposal_of(2).unwrap();
330
+ m.decline(2, id);
331
+ assert_eq!(m.status(1, 10), Place::Queued { rating: 1000, waited_ms: 10, window: 100 }, "keeps its place");
332
+ assert_eq!(m.status(2, 10), Place::Idle);
333
+ m.join(2, 1000, 20).unwrap();
334
+ m.propose(20);
335
+ m.accept(1).unwrap();
336
+ let done = m.accept(2).unwrap().expect("complete");
337
+ assert_eq!(done.players(), vec![1, 2]);
338
+ }
339
+
340
+ #[test]
341
+ fn an_expired_proposal_requeues_only_who_accepted() {
342
+ let mut m = Matchmaker::default();
343
+ m.join(1, 1000, 0).unwrap();
344
+ m.join(2, 1000, 0).unwrap();
345
+ m.propose(0);
346
+ m.accept(1).unwrap();
347
+ m.expire(m.config.accept_ms);
348
+ assert!(m.proposals.is_empty());
349
+ assert_eq!(m.queue.iter().map(|e| e.player).collect::<Vec<_>>(), vec![1]);
350
+ }
351
+
352
+ #[test]
353
+ fn a_player_with_a_match_to_accept_cannot_rejoin() {
354
+ let mut m = Matchmaker::default();
355
+ m.join(1, 1000, 0).unwrap();
356
+ m.join(2, 1000, 0).unwrap();
357
+ m.propose(0);
358
+ assert!(m.join(1, 900, 1).is_err());
359
+ m.leave(1);
360
+ assert_eq!(m.queue.iter().map(|e| e.player).collect::<Vec<_>>(), vec![2]);
361
+ }
362
+ }
@@ -0,0 +1,14 @@
1
+ [package]
2
+ name = "npc-mobs"
3
+ description = "Starter: an area's mobs and NPCs on a timer, shown to nearby players as realtime actors"
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"