@crowdedkingdoms/ckx-kit 0.2.0 → 0.4.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 (61) hide show
  1. package/README.md +13 -1
  2. package/crates/ckx-sdk/Cargo.toml +1 -1
  3. package/crates/ckx-sdk/src/kit/ai/brain.rs +288 -0
  4. package/crates/ckx-sdk/src/kit/ai/flow.rs +163 -0
  5. package/crates/ckx-sdk/src/kit/ai/mod.rs +10 -0
  6. package/crates/ckx-sdk/src/kit/ai/path.rs +214 -0
  7. package/crates/ckx-sdk/src/kit/ai/steering.rs +137 -0
  8. package/crates/ckx-sdk/src/kit/ai/turn.rs +141 -0
  9. package/crates/ckx-sdk/src/kit/mod.rs +542 -0
  10. package/crates/ckx-sdk/src/kit/play/abilities.rs +370 -0
  11. package/crates/ckx-sdk/src/kit/play/cards.rs +220 -0
  12. package/crates/ckx-sdk/src/kit/play/combat.rs +292 -0
  13. package/crates/ckx-sdk/src/kit/play/mod.rs +15 -0
  14. package/crates/ckx-sdk/src/kit/play/movement.rs +196 -0
  15. package/crates/ckx-sdk/src/kit/play/score.rs +183 -0
  16. package/crates/ckx-sdk/src/kit/play/timing.rs +218 -0
  17. package/crates/ckx-sdk/src/kit/play/turns.rs +315 -0
  18. package/crates/ckx-sdk/src/kit/presence.rs +142 -0
  19. package/crates/ckx-sdk/src/kit/rng.rs +137 -0
  20. package/crates/ckx-sdk/src/kit/shard.rs +65 -0
  21. package/crates/ckx-sdk/src/kit/sim/daycycle.rs +71 -0
  22. package/crates/ckx-sdk/src/kit/sim/mod.rs +10 -0
  23. package/crates/ckx-sdk/src/kit/sim/nodes.rs +109 -0
  24. package/crates/ckx-sdk/src/kit/sim/waves.rs +105 -0
  25. package/crates/ckx-sdk/src/kit/sim/weather.rs +142 -0
  26. package/crates/ckx-sdk/src/kit/sim/zones.rs +218 -0
  27. package/crates/ckx-sdk/src/kit/wire.rs +266 -0
  28. package/crates/ckx-sdk/src/lib.rs +13 -0
  29. package/crates/ckx-sdk/src/model.rs +5 -0
  30. package/crates/crowdy-client-sdk/Cargo.toml +1 -1
  31. package/dist/cli/src/new.js +9 -0
  32. package/kit.pin +4 -4
  33. package/package.json +1 -1
  34. package/starters/arena/Cargo.toml +14 -0
  35. package/starters/arena/src/lib.rs +849 -0
  36. package/starters/ckx.json +12 -1
  37. package/starters/deck/Cargo.toml +14 -0
  38. package/starters/deck/src/lib.rs +510 -0
  39. package/starters/director/Cargo.toml +14 -0
  40. package/starters/director/src/lib.rs +628 -0
  41. package/starters/grid-mod/Cargo.toml +1 -1
  42. package/starters/instance/Cargo.toml +14 -0
  43. package/starters/instance/src/lib.rs +656 -0
  44. package/starters/leaderboard/Cargo.toml +14 -0
  45. package/starters/leaderboard/src/lib.rs +395 -0
  46. package/starters/matchmaker/Cargo.toml +1 -1
  47. package/starters/matchmaker/src/lib.rs +334 -74
  48. package/starters/minigame/Cargo.toml +14 -0
  49. package/starters/minigame/src/lib.rs +391 -0
  50. package/starters/npc-mobs/Cargo.toml +1 -1
  51. package/starters/npc-mobs/src/lib.rs +277 -67
  52. package/starters/racing/Cargo.toml +14 -0
  53. package/starters/racing/src/lib.rs +1151 -0
  54. package/starters/session/Cargo.toml +1 -1
  55. package/starters/session/src/lib.rs +191 -86
  56. package/starters/tactics/Cargo.toml +14 -0
  57. package/starters/tactics/src/lib.rs +562 -0
  58. package/starters/territory/Cargo.toml +14 -0
  59. package/starters/territory/src/lib.rs +878 -0
  60. package/starters/world-tick/Cargo.toml +1 -1
  61. package/starters/world-tick/src/lib.rs +151 -38
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.9.0"
12
+ ckx-sdk = "0.11.0"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -7,12 +7,24 @@
7
7
  //! Endpoints: `state` (anyone), `harvest { node }` (players), and for the app's developers
8
8
  //! `schedule { name, start_ms, end_ms, modifiers }` and `cancel { name }`. The spawn seed, if
9
9
  //! any, is a JSON [`Config`].
10
+ //!
11
+ //! It speaks the kit protocol (`ckx_sdk::kit`). The `world` topic carries the [`Conditions`]
12
+ //! as `snapshot` messages; the `nodes` topic carries each resource node's changes as `delta`s
13
+ //! (`id`, `type_name` `Node`, `props` `amount` and `regrow_at`). `snapshot { topic }` answers
14
+ //! either. Changes may carry an `action_id`; refusals carry `kit:<code>:` codes. It is the root
15
+ //! hub, which takes 50 calls a second for the whole app: clients subscribe to its topics
16
+ //! rather than polling `state`.
10
17
 
11
18
  use std::collections::BTreeMap;
12
19
 
20
+ use ckx_sdk::kit::{self, Containers, Publisher, Receipts, codes};
21
+ use ckx_sdk::model::Published;
13
22
  use ckx_sdk::prelude::*;
23
+ use serde_json::json;
14
24
 
15
25
  pub const TICK_MS: u64 = 1_000;
26
+ pub const WORLD_TOPIC: &str = "world";
27
+ pub const NODES_TOPIC: &str = "nodes";
16
28
  /// The longest step the clock takes at once, so a world that was stopped resumes where it was.
17
29
  const MAX_STEP_MS: u64 = 5_000;
18
30
  const HARVEST_COOLDOWN_MS: u64 = 500;
@@ -131,8 +143,8 @@ impl World {
131
143
  x
132
144
  }
133
145
 
134
- /// Advances the world to `now` (Unix milliseconds).
135
- pub fn tick(&mut self, now: u64) {
146
+ /// Advances the world to `now` (Unix milliseconds), and returns the nodes that grew back.
147
+ pub fn tick(&mut self, now: u64) -> Vec<u32> {
136
148
  self.ticks += 1;
137
149
  self.clock_ms += now.saturating_sub(self.last_tick).min(MAX_STEP_MS);
138
150
  self.last_tick = now;
@@ -147,14 +159,17 @@ impl World {
147
159
  self.weather_until = self.clock_ms + hour * (2 + self.next_rand() % 5);
148
160
  }
149
161
  let amount = self.config.node_amount;
162
+ let mut regrown = Vec::new();
150
163
  for n in &mut self.nodes {
151
164
  if n.regrow_at.is_some_and(|at| now >= at) {
152
165
  n.amount = amount;
153
166
  n.regrow_at = None;
167
+ regrown.push(n.id);
154
168
  }
155
169
  }
156
170
  self.harvested.retain(|_, at| now.saturating_sub(*at) < 60_000);
157
171
  self.events.retain(|_, e| e.end_ms > now);
172
+ regrown
158
173
  }
159
174
 
160
175
  pub fn conditions(&self, now: u64) -> Conditions {
@@ -171,12 +186,13 @@ impl World {
171
186
 
172
187
  pub fn harvest(&mut self, player: u64, node: u32, now: u64) -> Result<u32> {
173
188
  if self.harvested.get(&player).is_some_and(|at| now < at + HARVEST_COOLDOWN_MS) {
174
- return Err(Error::new("harvesting too fast"));
189
+ return Err(kit::error(codes::COOLDOWN, "harvesting too fast"));
175
190
  }
176
191
  let regrow_at = now + self.config.regrow_ms;
177
- let n = self.nodes.iter_mut().find(|n| n.id == node).ok_or_else(|| Error::new(format!("no resource node {node}")))?;
192
+ let n =
193
+ self.nodes.iter_mut().find(|n| n.id == node).ok_or_else(|| kit::error(codes::NOT_FOUND, format!("no resource node {node}")))?;
178
194
  if n.amount == 0 {
179
- return Err(Error::new("that node is empty; it grows back"));
195
+ return Err(kit::error(codes::EMPTY, "that node is empty; it grows back"));
180
196
  }
181
197
  n.amount -= 1;
182
198
  if n.amount == 0 {
@@ -188,21 +204,32 @@ impl World {
188
204
 
189
205
  pub fn schedule(&mut self, event: Event, now: u64) -> Result<()> {
190
206
  if event.name.is_empty() || event.name.len() > 64 {
191
- return Err(Error::new("an event name is 1-64 characters"));
207
+ return Err(kit::error(codes::BAD_REQUEST, "an event name is 1-64 characters"));
192
208
  }
193
209
  if event.end_ms <= event.start_ms || event.end_ms <= now {
194
- return Err(Error::new("an event ends after it starts, and in the future"));
210
+ return Err(kit::error(codes::BAD_REQUEST, "an event ends after it starts, and in the future"));
195
211
  }
196
212
  if event.modifiers.len() > MAX_MODIFIERS || event.modifiers.values().any(|v| !v.is_finite() || *v <= 0.0 || *v > 100.0) {
197
- return Err(Error::new(format!("at most {MAX_MODIFIERS} modifiers, each above 0 and at most 100")));
213
+ return Err(kit::error(codes::BAD_REQUEST, format!("at most {MAX_MODIFIERS} modifiers, each above 0 and at most 100")));
198
214
  }
199
215
  if !self.events.contains_key(&event.name) && self.events.len() >= MAX_EVENTS {
200
- return Err(Error::new(format!("at most {MAX_EVENTS} events are scheduled at once")));
216
+ return Err(kit::error(codes::LIMIT, format!("at most {MAX_EVENTS} events are scheduled at once")));
201
217
  }
202
218
  self.events.insert(event.name.clone(), event);
203
219
  Ok(())
204
220
  }
205
221
 
222
+ /// A resource node as the `nodes` topic shows it.
223
+ pub fn node_delta(&self, id: u32) -> Option<Published> {
224
+ let n = self.nodes.iter().find(|n| n.id == id)?;
225
+ let props = BTreeMap::from([("amount".to_owned(), json!(n.amount)), ("regrow_at".to_owned(), json!(n.regrow_at))]);
226
+ Some(Published { id: u64::from(n.id), type_name: Some("Node".to_owned()), props: Some(props), removed: false })
227
+ }
228
+
229
+ pub fn node_collection(&self) -> Containers {
230
+ Containers { containers: self.nodes.iter().filter_map(|n| self.node_delta(n.id)).collect() }
231
+ }
232
+
206
233
  pub fn view(&self, now: u64) -> View {
207
234
  View {
208
235
  conditions: self.conditions(now),
@@ -214,7 +241,66 @@ impl World {
214
241
  }
215
242
  }
216
243
 
217
- impl Hub for World {
244
+ /// The hub: the world, and what the kit protocol keeps beside it.
245
+ #[derive(Serialize, Deserialize, Default)]
246
+ pub struct WorldHub {
247
+ #[serde(flatten)]
248
+ pub world: World,
249
+ #[serde(default)]
250
+ pub kit: Publisher,
251
+ #[serde(default)]
252
+ pub receipts: Receipts,
253
+ }
254
+
255
+ fn developer(call: &Call<'_>) -> Result<u64> {
256
+ call.developer().map_err(|_| kit::error(codes::FORBIDDEN, format!("`{}` is for the app's developers", call.method)))
257
+ }
258
+
259
+ impl WorldHub {
260
+ fn act(&mut self, ctx: &Ctx, call: &Call<'_>, now: u64) -> Result<Vec<u8>> {
261
+ match call.method {
262
+ "harvest" => {
263
+ let player = call.player()?;
264
+ let req: Harvest = kit::args(call)?;
265
+ let left = self.world.harvest(player, req.node, now)?;
266
+ if let Some(d) = self.world.node_delta(req.node) {
267
+ self.kit.delta(ctx, NODES_TOPIC, &d)?;
268
+ }
269
+ encode(&left)
270
+ }
271
+ "schedule" => {
272
+ developer(call)?;
273
+ self.world.schedule(kit::args(call)?, now)?;
274
+ ctx.persist_now();
275
+ self.publish_conditions(ctx, now, true)?;
276
+ encode(&true)
277
+ }
278
+ "cancel" => {
279
+ developer(call)?;
280
+ let req: Cancel = kit::args(call)?;
281
+ let removed = self.world.events.remove(&req.name).is_some();
282
+ self.publish_conditions(ctx, now, true)?;
283
+ encode(&removed)
284
+ }
285
+ other => Err(Error::unknown_method(other)),
286
+ }
287
+ }
288
+
289
+ /// Publishes the conditions when they changed in a way players notice, or `always`, and
290
+ /// every [`PUBLISH_EVERY`] ticks regardless so clients keep the clock.
291
+ fn publish_conditions(&mut self, ctx: &Ctx, now: u64, always: bool) -> Result<()> {
292
+ let c = self.world.conditions(now);
293
+ let notable =
294
+ self.world.published.as_ref().is_none_or(|p| p.night != c.night || p.weather != c.weather || p.modifiers != c.modifiers);
295
+ if always || notable || self.world.ticks.is_multiple_of(PUBLISH_EVERY) {
296
+ self.kit.snapshot(ctx, WORLD_TOPIC, &c)?;
297
+ self.world.published = Some(c);
298
+ }
299
+ Ok(())
300
+ }
301
+ }
302
+
303
+ impl Hub for WorldHub {
218
304
  fn spawn(ctx: &Ctx, seed: &[u8]) -> Result<Self> {
219
305
  let config = if seed.is_empty() {
220
306
  Config::default()
@@ -222,9 +308,10 @@ impl Hub for World {
222
308
  serde_json::from_slice(seed).map_err(|e| Error::new(format!("seed is not a JSON config: {e}")))?
223
309
  };
224
310
  ctx.timer_every("tick", TICK_MS)?;
225
- Ok(Self::new(config, ctx.now_ms()))
311
+ Ok(Self { world: World::new(config, ctx.now_ms()), ..Default::default() })
226
312
  }
227
313
 
314
+ /// Reads this version's snapshots and the earlier ones, which held the world alone.
228
315
  fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
229
316
  decode(snapshot)
230
317
  }
@@ -236,24 +323,13 @@ impl Hub for World {
236
323
  fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
237
324
  let now = ctx.now_ms();
238
325
  match call.method {
239
- "state" => encode(&self.view(now)),
240
- "harvest" => {
241
- let player = call.player()?;
242
- let req: Harvest = call.decode()?;
243
- encode(&self.harvest(player, req.node, now)?)
244
- }
245
- "schedule" => {
246
- call.developer()?;
247
- self.schedule(call.decode()?, now)?;
248
- ctx.persist_now();
249
- encode(&true)
250
- }
251
- "cancel" => {
252
- call.developer()?;
253
- let req: Cancel = call.decode()?;
254
- encode(&self.events.remove(&req.name).is_some())
255
- }
256
- other => Err(Error::unknown_method(other)),
326
+ "state" => encode(&self.world.view(now)),
327
+ kit::SNAPSHOT_METHOD => match kit::snapshot_topic(&call, WORLD_TOPIC).as_str() {
328
+ WORLD_TOPIC => self.kit.snapshot_reply(ctx, WORLD_TOPIC, &self.world.conditions(now)),
329
+ NODES_TOPIC => self.kit.snapshot_reply(ctx, NODES_TOPIC, &self.world.node_collection()),
330
+ other => Err(kit::error(codes::NOT_FOUND, format!("no topic `{other}`"))),
331
+ },
332
+ _ => Receipts::run(self, |h| &mut h.receipts, &call, now, |h| h.act(ctx, &call, now)),
257
333
  }
258
334
  }
259
335
 
@@ -262,23 +338,21 @@ impl Hub for World {
262
338
  return Ok(());
263
339
  }
264
340
  let now = ctx.now_ms();
265
- self.tick(now);
266
- let c = self.conditions(now);
267
- let notable = self.published.as_ref().is_none_or(|p| p.night != c.night || p.weather != c.weather || p.modifiers != c.modifiers);
268
- if notable || self.ticks.is_multiple_of(PUBLISH_EVERY) {
269
- ctx.publish("world", &encode(&c)?);
270
- self.published = Some(c);
341
+ for id in self.world.tick(now) {
342
+ if let Some(d) = self.world.node_delta(id) {
343
+ self.kit.delta(ctx, NODES_TOPIC, &d)?;
344
+ }
271
345
  }
272
- Ok(())
346
+ self.publish_conditions(ctx, now, false)
273
347
  }
274
348
 
275
349
  fn on_presence(&mut self, _ctx: &Ctx, presence: &Presence) -> Result<()> {
276
- self.online = presence.count;
350
+ self.world.online = presence.count;
277
351
  Ok(())
278
352
  }
279
353
  }
280
354
 
281
- ckx_sdk::export_hub!(World);
355
+ ckx_sdk::export_hub!(WorldHub);
282
356
 
283
357
  #[cfg(test)]
284
358
  mod tests {
@@ -343,4 +417,43 @@ mod tests {
343
417
  let c: Config = serde_json::from_slice(br#"{"nodes": 3}"#).unwrap();
344
418
  assert_eq!((c.nodes, c.node_amount), (3, Config::default().node_amount));
345
419
  }
420
+
421
+ #[derive(Serialize)]
422
+ struct H<'a> {
423
+ node: u32,
424
+ action_id: &'a str,
425
+ }
426
+
427
+ #[test]
428
+ fn harvests_and_regrowth_are_deltas_on_the_nodes_topic_and_a_resent_harvest_takes_once() {
429
+ use ckx_sdk::kit::{Message, MessageKind};
430
+ use ckx_sdk::testing::TestHub;
431
+ let seed = br#"{"nodes": 2, "node_amount": 1, "regrow_ms": 1000}"#;
432
+ let mut hub = TestHub::<WorldHub>::spawn_with("world", "", seed).unwrap();
433
+ let left: u32 = hub.call_typed(Caller::Player(1), "harvest", &H { node: 0, action_id: "h1" }).unwrap();
434
+ let again: u32 = hub.call_typed(Caller::Player(1), "harvest", &H { node: 0, action_id: "h1" }).unwrap();
435
+ assert_eq!((left, again), (0, 0));
436
+ let e = hub.call(Caller::Player(1), "harvest", &encode(&H { node: 0, action_id: "h2" }).unwrap()).unwrap_err();
437
+ assert_eq!(kit::error_code(&e.message).map(|c| c.0), Some(codes::COOLDOWN));
438
+ hub.advance(1_500).unwrap();
439
+ let deltas: Vec<Message<Published>> =
440
+ hub.published().iter().filter(|(t, _)| t == NODES_TOPIC).map(|(_, b)| decode(b).unwrap()).collect();
441
+ assert_eq!(deltas.len(), 2, "emptied, then grown back");
442
+ assert!(deltas.iter().all(|m| m.header.kind == MessageKind::Delta && m.body.id == 0));
443
+ assert_eq!(deltas[1].body.props.as_ref().unwrap()["amount"], json!(1));
444
+ let conditions: Vec<Message<Conditions>> =
445
+ hub.published().iter().filter(|(t, _)| t == WORLD_TOPIC).map(|(_, b)| decode(b).unwrap()).collect();
446
+ assert!(!conditions.is_empty(), "the first tick publishes the conditions");
447
+ let nodes: Message<Containers> = hub.call_typed(Caller::Player(2), "snapshot", &json!({ "topic": NODES_TOPIC })).unwrap();
448
+ assert_eq!((nodes.body.containers.len(), nodes.header.seq), (2, 2));
449
+ assert!(hub.call(Caller::Player(1), "schedule", &[]).is_err(), "developers only");
450
+ }
451
+
452
+ #[test]
453
+ fn a_snapshot_from_before_the_kit_protocol_loads() {
454
+ use ckx_sdk::testing::TestHub;
455
+ let old = encode(&world()).unwrap();
456
+ let hub = TestHub::<WorldHub>::load("world", "", &old, 1).unwrap();
457
+ assert_eq!(hub.hub().world.nodes.len(), 2);
458
+ }
346
459
  }