@crowdedkingdoms/ckx-kit 0.2.0-test.1 → 0.3.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,21 +1,31 @@
1
- //! The mobs and NPCs of one area of the world, keyed `x,z` (the area's centre chunk), moved on a
2
- //! timer and shown to nearby players as server-driven actors. Mobs wander near home, chase the
3
- //! nearest player in range, go home past their leash, take players' hits and respawn; NPCs
4
- //! walk their routes and talk. Mobs see further at night and under an event's `aggro`
5
- //! modifier, from the root `world` hub's topic. Mobs and NPCs are typed containers
6
- //! ([`ckx_sdk::model`]), so players read only their public properties. Replaces the
7
- //! `mob-engine` and `npc-engine` templates.
1
+ //! The mobs, NPCs and pets of one area of the world, keyed `x,z` (the area's centre chunk), moved
2
+ //! on a timer and shown to nearby players as server-driven actors. Mobs wander near home, chase
3
+ //! the nearest player in range, go home past their leash, take players' hits and respawn; NPCs
4
+ //! walk their routes and talk; pets belong to a player and follow them while summoned. Mobs see
5
+ //! further at night and under an event's `aggro` modifier, from the root `world` hub's topic.
6
+ //! All of them are typed containers ([`ckx_sdk::model`]), so players read only their public
7
+ //! properties (a pet's owner also reads its bond). Replaces the `mob-engine` and `npc-engine`
8
+ //! templates.
8
9
  //!
9
- //! Endpoints: `state` (anyone), `attack { mob }` and `talk { npc }` (players). The type needs
10
- //! the `world.read` scope (where players are) and may call `world` (for its topic). Changes to
11
- //! public properties go out on the `area` topic once a second.
10
+ //! Endpoints: `state` (anyone), `attack { mob }` and `talk { npc }` (players), and a player's
11
+ //! pets: `adopt { species, name }`, `rename { pet, name }`, `summon { pet }`, `dismiss { pet }`,
12
+ //! `release { pet }` and `pets` (the caller's, with their bond). The type needs the
13
+ //! `world.read` scope (where players are) and may call `world` (for its topic).
14
+ //!
15
+ //! It speaks the kit protocol (`ckx_sdk::kit`): public property changes go out on the `area`
16
+ //! topic as `delta`s once a second, `snapshot { topic: "area" }` answers every container's
17
+ //! public view, changes may carry an `action_id`, and refusals carry `kit:<code>:` codes.
18
+ //! Actors are shown with [`ActorState`] (`kit::wire`).
12
19
 
13
20
  use std::collections::BTreeMap;
14
21
 
22
+ use ckx_sdk::kit::wire::{self, ActorState};
23
+ use ckx_sdk::kit::{self, Publisher, Receipts, codes};
15
24
  use ckx_sdk::prelude::*;
16
25
  use serde_json::{Value, json};
17
26
 
18
27
  pub const TICK_MS: u64 = 250;
28
+ pub const TOPIC: &str = "area";
19
29
  /// Ticks between scans of where players are, and between `area` pushes.
20
30
  const SCAN_EVERY: u64 = 4;
21
31
  const MOBS: u64 = 6;
@@ -24,6 +34,9 @@ const AGGRO: f64 = 2.5;
24
34
  const LEASH: f64 = 6.0;
25
35
  const MOB_SPEED: f64 = 0.5;
26
36
  const NPC_SPEED: f64 = 0.25;
37
+ const PET_SPEED: f64 = 1.0;
38
+ /// How close a following pet keeps to its owner.
39
+ const PET_HEEL: f64 = 0.8;
27
40
  /// How near a player must be to hit a mob.
28
41
  const REACH: f64 = 1.5;
29
42
  /// The server decides a hit's damage, never the client.
@@ -32,6 +45,10 @@ const HIT_COOLDOWN_MS: u64 = 500;
32
45
  const RESPAWN_MS: u64 = 10_000;
33
46
  /// Players within this many chunks of a mob see it move.
34
47
  const SHOW_DISTANCE: u8 = 2;
48
+ /// Pets one player may keep in an area, and the area may hold in all.
49
+ pub const PETS_PER_PLAYER: usize = 3;
50
+ const PETS_MAX: usize = 64;
51
+ const NAME_MAX: usize = 24;
35
52
 
36
53
  pub fn schema() -> Schema {
37
54
  Schema::new()
@@ -54,16 +71,14 @@ pub fn schema() -> Schema {
54
71
  .prop("route", PropKind::Json, json!([]), Visibility::Hidden)
55
72
  .prop("leg", PropKind::Int, 0, Visibility::Hidden)
56
73
  })
57
- }
58
-
59
- /// What players near an actor receive as its state.
60
- #[derive(Serialize, Deserialize, Debug, PartialEq)]
61
- pub struct ActorState {
62
- pub kind: String,
63
- pub id: u64,
64
- pub x: f64,
65
- pub z: f64,
66
- pub hp: Option<i64>,
74
+ .container("Pet", |c| {
75
+ c.prop("species", PropKind::Str, "cat", Visibility::Public)
76
+ .prop("name", PropKind::Str, "", Visibility::Public)
77
+ .prop("x", PropKind::Float, 0.0, Visibility::Public)
78
+ .prop("z", PropKind::Float, 0.0, Visibility::Public)
79
+ .prop("mode", PropKind::Str, "stay", Visibility::Public)
80
+ .prop("bond", PropKind::Int, 0, Visibility::Owner)
81
+ })
67
82
  }
68
83
 
69
84
  #[derive(Deserialize)]
@@ -83,6 +98,19 @@ struct Talk {
83
98
  npc: u64,
84
99
  }
85
100
 
101
+ #[derive(Deserialize)]
102
+ struct Adopt {
103
+ species: String,
104
+ name: String,
105
+ }
106
+
107
+ #[derive(Deserialize)]
108
+ struct PetRef {
109
+ pet: u64,
110
+ #[serde(default)]
111
+ name: Option<String>,
112
+ }
113
+
86
114
  fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {
87
115
  ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
88
116
  }
@@ -96,6 +124,14 @@ fn toward(from: (f64, f64), to: (f64, f64), step: f64) -> (f64, f64) {
96
124
  (from.0 + (to.0 - from.0) * step / d, from.1 + (to.1 - from.1) * step / d)
97
125
  }
98
126
 
127
+ fn short_text(what: &str, s: &str, max: usize) -> Result<String> {
128
+ let t = s.trim();
129
+ if t.is_empty() || t.chars().count() > max {
130
+ return Err(kit::error(codes::BAD_REQUEST, format!("a {what} is 1 to {max} characters")));
131
+ }
132
+ Ok(t.to_owned())
133
+ }
134
+
99
135
  #[derive(Serialize, Deserialize)]
100
136
  pub struct Area {
101
137
  #[serde(skip, default = "schema")]
@@ -112,7 +148,7 @@ pub struct Area {
112
148
 
113
149
  impl Area {
114
150
  pub fn new(key: &str) -> Result<Self> {
115
- let center = key.split_once(',').and_then(|(x, z)| Some((x.trim().parse().ok()?, z.trim().parse().ok()?))).unwrap_or((0, 0));
151
+ let center = kit::shard::parse(key).unwrap_or((0, 0));
116
152
  let mut area = Self {
117
153
  schema: schema(),
118
154
  model: Model::new(),
@@ -228,20 +264,39 @@ impl Area {
228
264
  Ok(())
229
265
  }
230
266
 
267
+ /// Summoned pets walk to heel beside their owner, while the owner is in the area.
268
+ pub fn step_pets(&mut self, dt: f64) -> Result<()> {
269
+ let ids: Vec<u64> = self.model.of_type("Pet").collect();
270
+ for id in ids {
271
+ if self.model.get_str(&self.schema, id, "mode")? != "follow" {
272
+ continue;
273
+ }
274
+ let Some(owner) = self.model.container(id).and_then(|c| c.owner) else { continue };
275
+ let Some(&target) = self.players.get(&owner) else { continue };
276
+ let at = self.pos(id)?;
277
+ if dist(at, target) > PET_HEEL {
278
+ let d = dist(at, target);
279
+ let heel = (target.0 + (at.0 - target.0) * PET_HEEL / d, target.1 + (at.1 - target.1) * PET_HEEL / d);
280
+ self.place(id, toward(at, heel, PET_SPEED * dt))?;
281
+ }
282
+ }
283
+ Ok(())
284
+ }
285
+
231
286
  /// A player's hit on a mob: in reach at the last scan, not too fast, fixed damage.
232
287
  pub fn attack(&mut self, player: u64, mob: u64, now: u64) -> Result<i64> {
233
288
  if self.model.container(mob).is_none_or(|c| c.type_name != "Mob") {
234
- return Err(Error::new(format!("no mob {mob}")));
289
+ return Err(kit::error(codes::NOT_FOUND, format!("no mob {mob}")));
235
290
  }
236
291
  if self.last_hit.get(&player).is_some_and(|at| now < at + HIT_COOLDOWN_MS) {
237
- return Err(Error::new("attacking too fast"));
292
+ return Err(kit::error(codes::COOLDOWN, "attacking too fast"));
238
293
  }
239
- let at = *self.players.get(&player).ok_or_else(|| Error::new("you are not in this area"))?;
294
+ let at = *self.players.get(&player).ok_or_else(|| kit::error("not_in_area", "you are not in this area"))?;
240
295
  if dist(at, self.pos(mob)?) > REACH {
241
- return Err(Error::new("out of reach"));
296
+ return Err(kit::error(codes::OUT_OF_REACH, "out of reach"));
242
297
  }
243
298
  if self.model.get_i64(&self.schema, mob, "respawn_at")? > 0 {
244
- return Err(Error::new("that mob is down"));
299
+ return Err(kit::error("down", "that mob is down"));
245
300
  }
246
301
  self.last_hit.insert(player, now);
247
302
  let hp = self.model.add(&self.schema, mob, "hp", -HIT)?;
@@ -253,8 +308,65 @@ impl Area {
253
308
  Ok(hp.max(0))
254
309
  }
255
310
 
311
+ /// A new pet for `player`, standing where they stand.
312
+ pub fn adopt(&mut self, player: u64, species: &str, name: &str) -> Result<u64> {
313
+ let at = *self.players.get(&player).ok_or_else(|| kit::error("not_in_area", "you are not in this area"))?;
314
+ let (species, name) = (short_text("species", species, NAME_MAX)?, short_text("name", name, NAME_MAX)?);
315
+ if self.pets_of(player).len() >= PETS_PER_PLAYER {
316
+ return Err(kit::error(codes::LIMIT, format!("at most {PETS_PER_PLAYER} pets a player in an area")));
317
+ }
318
+ if self.model.of_type("Pet").count() >= PETS_MAX {
319
+ return Err(kit::error(codes::FULL, "this area has as many pets as it holds"));
320
+ }
321
+ let id = self.model.create(&self.schema, "Pet", Some(player))?;
322
+ self.model.set(&self.schema, id, "species", species)?;
323
+ self.model.set(&self.schema, id, "name", name)?;
324
+ self.place(id, at)?;
325
+ Ok(id)
326
+ }
327
+
328
+ fn own_pet(&self, player: u64, pet: u64) -> Result<()> {
329
+ match self.model.container(pet) {
330
+ Some(c) if c.type_name == "Pet" && c.owner == Some(player) => Ok(()),
331
+ Some(c) if c.type_name == "Pet" => Err(kit::error(codes::FORBIDDEN, "that is someone else's pet")),
332
+ _ => Err(kit::error(codes::NOT_FOUND, format!("no pet {pet}"))),
333
+ }
334
+ }
335
+
336
+ /// The player's pets as their owner reads them, bond included.
337
+ pub fn pets_of(&self, player: u64) -> Vec<BTreeMap<String, Value>> {
338
+ self.model
339
+ .of_type("Pet")
340
+ .filter(|id| self.model.container(*id).is_some_and(|c| c.owner == Some(player)))
341
+ .filter_map(|id| {
342
+ let mut v = self.model.view(&self.schema, id, Viewer::Player(player)).ok()?;
343
+ v.insert("id".into(), json!(id));
344
+ Some(v)
345
+ })
346
+ .collect()
347
+ }
348
+
349
+ pub fn pet_command(&mut self, player: u64, method: &str, pet: u64, name: Option<String>) -> Result<()> {
350
+ self.own_pet(player, pet)?;
351
+ match method {
352
+ "rename" => {
353
+ let name = short_text("name", name.as_deref().unwrap_or(""), NAME_MAX)?;
354
+ self.model.set(&self.schema, pet, "name", name)?;
355
+ }
356
+ "summon" => {
357
+ self.set_mode(pet, "follow")?;
358
+ self.model.add(&self.schema, pet, "bond", 1)?;
359
+ }
360
+ "dismiss" => self.set_mode(pet, "stay")?,
361
+ _ => {
362
+ self.model.delete(pet);
363
+ }
364
+ }
365
+ Ok(())
366
+ }
367
+
256
368
  pub fn state(&self) -> Result<Vec<BTreeMap<String, Value>>> {
257
- let ids = self.model.of_type("Mob").chain(self.model.of_type("Npc"));
369
+ let ids = self.model.of_type("Mob").chain(self.model.of_type("Npc")).chain(self.model.of_type("Pet"));
258
370
  ids.map(|id| {
259
371
  let mut v = self.model.view(&self.schema, id, Viewer::Public)?;
260
372
  v.insert("id".into(), json!(id));
@@ -274,39 +386,83 @@ impl Area {
274
386
  }
275
387
  }
276
388
 
277
- fn show(&self, ctx: &Ctx) -> Result<()> {
278
- for type_name in ["Mob", "Npc"] {
389
+ /// What players near each actor see of it.
390
+ pub fn actor_states(&self) -> Result<Vec<([u8; 32], ActorState)>> {
391
+ let mut out = Vec::new();
392
+ for type_name in ["Mob", "Npc", "Pet"] {
279
393
  for id in self.model.of_type(type_name) {
280
- let hp = if type_name == "Mob" {
281
- if self.model.get_i64(&self.schema, id, "respawn_at")? > 0 {
282
- continue;
283
- }
284
- Some(self.model.get_i64(&self.schema, id, "hp")?)
285
- } else {
286
- None
287
- };
288
394
  let (x, z) = self.pos(id)?;
395
+ let mut state = ActorState::new(&type_name.to_lowercase(), id, x, z);
396
+ match type_name {
397
+ "Mob" => {
398
+ if self.model.get_i64(&self.schema, id, "respawn_at")? > 0 {
399
+ continue;
400
+ }
401
+ state.hp = Some(self.model.get_i64(&self.schema, id, "hp")?);
402
+ state.max_hp = Some(self.model.get_i64(&self.schema, id, "max_hp")?);
403
+ state.mode = Some(self.model.get_str(&self.schema, id, "mode")?);
404
+ }
405
+ "Pet" => {
406
+ state.owner = self.model.container(id).and_then(|c| c.owner);
407
+ state.name = Some(self.model.get_str(&self.schema, id, "name")?);
408
+ state.mode = Some(self.model.get_str(&self.schema, id, "mode")?);
409
+ }
410
+ _ => state.name = Some(self.model.get_str(&self.schema, id, "name")?),
411
+ }
289
412
  let actor = actor_id(&format!("{}{},{}:{id}", &type_name[..1], self.center.0, self.center.1));
290
- let state = ActorState { kind: type_name.to_lowercase(), id, x, z, hp };
291
- let _ = ctx.emit_actor(&actor, ChunkPos::new(x.floor() as i64, 0, z.floor() as i64), SHOW_DISTANCE, &encode(&state)?);
413
+ out.push((actor, state));
292
414
  }
293
415
  }
294
- Ok(())
416
+ Ok(out)
295
417
  }
296
418
  }
297
419
 
298
- impl Hub for Area {
420
+ /// The hub: the area, and what the kit protocol keeps beside it.
421
+ #[derive(Serialize, Deserialize)]
422
+ pub struct AreaHub {
423
+ #[serde(flatten)]
424
+ pub area: Area,
425
+ #[serde(default)]
426
+ pub kit: Publisher,
427
+ #[serde(default)]
428
+ pub receipts: Receipts,
429
+ }
430
+
431
+ impl AreaHub {
432
+ fn act(&mut self, call: &Call<'_>, now: u64) -> Result<Vec<u8>> {
433
+ let player = call.player()?;
434
+ match call.method {
435
+ "attack" => {
436
+ let req: Attack = kit::args(call)?;
437
+ encode(&self.area.attack(player, req.mob, now)?)
438
+ }
439
+ "adopt" => {
440
+ let req: Adopt = kit::args(call)?;
441
+ encode(&self.area.adopt(player, &req.species, &req.name)?)
442
+ }
443
+ "rename" | "summon" | "dismiss" | "release" => {
444
+ let req: PetRef = kit::args(call)?;
445
+ self.area.pet_command(player, call.method, req.pet, req.name)?;
446
+ encode(&true)
447
+ }
448
+ other => Err(Error::unknown_method(other)),
449
+ }
450
+ }
451
+ }
452
+
453
+ impl Hub for AreaHub {
299
454
  fn spawn(ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
300
- let area = Self::new(&ctx.key)?;
455
+ let area = Area::new(&ctx.key)?;
301
456
  ctx.timer_every("tick", TICK_MS)?;
302
457
  // The root hub's key is empty. Without a `world` type (or leave to call it) the area
303
458
  // runs without day and night.
304
459
  if let Err(e) = ctx.subscribe("world", "", "world") {
305
460
  ctx.log(Level::Info, &format!("not watching the world: {e}"));
306
461
  }
307
- Ok(area)
462
+ Ok(Self { area, kit: Publisher::new(), receipts: Receipts::new() })
308
463
  }
309
464
 
465
+ /// Reads this version's snapshots and the earlier ones, which held the area alone.
310
466
  fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
311
467
  decode(snapshot)
312
468
  }
@@ -317,20 +473,18 @@ impl Hub for Area {
317
473
 
318
474
  fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
319
475
  match call.method {
320
- "state" => encode(&self.state()?),
321
- "attack" => {
322
- let req: Attack = call.decode()?;
323
- encode(&self.attack(call.player()?, req.mob, ctx.now_ms())?)
324
- }
476
+ "state" => encode(&self.area.state()?),
477
+ kit::SNAPSHOT_METHOD => self.kit.model_snapshot_reply(ctx, &self.area.model, &self.area.schema, TOPIC),
325
478
  "talk" => {
326
479
  call.player()?;
327
- let req: Talk = call.decode()?;
328
- if self.model.container(req.npc).is_none_or(|c| c.type_name != "Npc") {
329
- return Err(Error::new(format!("no npc {}", req.npc)));
480
+ let req: Talk = kit::args(&call)?;
481
+ if self.area.model.container(req.npc).is_none_or(|c| c.type_name != "Npc") {
482
+ return Err(kit::error(codes::NOT_FOUND, format!("no npc {}", req.npc)));
330
483
  }
331
- encode(&self.model.view(&self.schema, req.npc, Viewer::Public)?)
484
+ encode(&self.area.model.view(&self.area.schema, req.npc, Viewer::Public)?)
332
485
  }
333
- other => Err(Error::unknown_method(other)),
486
+ "pets" => encode(&self.area.pets_of(call.player()?)),
487
+ _ => Receipts::run(self, |h| &mut h.receipts, &call, ctx.now_ms(), |h| h.act(&call, ctx.now_ms())),
334
488
  }
335
489
  }
336
490
 
@@ -338,19 +492,23 @@ impl Hub for Area {
338
492
  if timer != "tick" {
339
493
  return Ok(());
340
494
  }
341
- self.ticks += 1;
342
- if self.ticks % SCAN_EVERY == 1 {
343
- self.scan(ctx);
495
+ let area = &mut self.area;
496
+ area.ticks += 1;
497
+ if area.ticks % SCAN_EVERY == 1 {
498
+ area.scan(ctx);
344
499
  }
345
500
  let dt = TICK_MS as f64 / 1_000.0;
346
- self.step_mobs(ctx.now_ms(), dt)?;
347
- self.step_npcs(dt)?;
348
- if !self.players.is_empty() {
349
- self.show(ctx)?;
501
+ area.step_mobs(ctx.now_ms(), dt)?;
502
+ area.step_npcs(dt)?;
503
+ area.step_pets(dt)?;
504
+ if !area.players.is_empty() {
505
+ for (actor, state) in area.actor_states()? {
506
+ let _ = wire::emit_actor_state(ctx, &actor, SHOW_DISTANCE, &state);
507
+ }
350
508
  }
351
- if self.ticks.is_multiple_of(SCAN_EVERY) {
352
- self.last_hit.retain(|_, at| ctx.now_ms().saturating_sub(*at) < 60_000);
353
- self.model.publish_changes(ctx, &self.schema, "area");
509
+ if area.ticks.is_multiple_of(SCAN_EVERY) {
510
+ area.last_hit.retain(|_, at| ctx.now_ms().saturating_sub(*at) < 60_000);
511
+ self.kit.model_changes(ctx, &mut self.area.model, &self.area.schema, TOPIC)?;
354
512
  }
355
513
  Ok(())
356
514
  }
@@ -358,18 +516,21 @@ impl Hub for Area {
358
516
  fn on_topic(&mut self, _ctx: &Ctx, msg: TopicMsg<'_>) -> Result<()> {
359
517
  if (msg.node_type, msg.topic) == ("world", "world") {
360
518
  let c: Conditions = msg.decode()?;
361
- self.night = c.night;
362
- self.aggro_scale = c.modifiers.get("aggro").copied().unwrap_or(1.0).clamp(0.1, 10.0);
519
+ self.area.night = c.night;
520
+ self.area.aggro_scale = c.modifiers.get("aggro").copied().unwrap_or(1.0).clamp(0.1, 10.0);
363
521
  }
364
522
  Ok(())
365
523
  }
366
524
  }
367
525
 
368
- ckx_sdk::export_hub!(Area);
526
+ ckx_sdk::export_hub!(AreaHub);
369
527
 
370
528
  #[cfg(test)]
371
529
  mod tests {
372
530
  use super::*;
531
+ use ckx_sdk::kit::{Containers, Message, MessageKind, error_code};
532
+ use ckx_sdk::model::Published;
533
+ use ckx_sdk::testing::TestHub;
373
534
 
374
535
  fn area() -> Area {
375
536
  Area::new("10,-4").unwrap()
@@ -420,7 +581,8 @@ mod tests {
420
581
  fn hits_need_reach_respect_the_cooldown_and_a_downed_mob_respawns() {
421
582
  let mut a = area();
422
583
  let m = first_mob(&a);
423
- assert!(a.attack(7, m, 0).is_err(), "not in the area");
584
+ let Error(why) = a.attack(7, m, 0).unwrap_err();
585
+ assert_eq!(error_code(&why).map(|c| c.0), Some("not_in_area"));
424
586
  let at = a.pos(m).unwrap();
425
587
  a.players.insert(7, (at.0 + REACH + 1.0, at.1));
426
588
  assert!(a.attack(7, m, 0).is_err(), "out of reach");
@@ -466,4 +628,52 @@ mod tests {
466
628
  assert!(views.iter().all(|v| !v.contains_key("route") && !v.contains_key("home_x") && !v.contains_key("respawn_at")));
467
629
  assert!(views.iter().any(|v| v.get("name") == Some(&json!("Warden"))));
468
630
  }
631
+
632
+ #[test]
633
+ fn a_pet_is_its_owners_follows_them_when_summoned_and_keeps_its_bond_private() {
634
+ let mut a = area();
635
+ assert!(a.adopt(7, "cat", "Mog").is_err(), "not in the area");
636
+ a.players.insert(7, (10.5, -3.5));
637
+ let pet = a.adopt(7, "cat", "Mog").unwrap();
638
+ a.players.insert(8, (11.5, -3.5));
639
+ let Error(why) = a.pet_command(8, "summon", pet, None).unwrap_err();
640
+ assert_eq!(error_code(&why).map(|c| c.0), Some(codes::FORBIDDEN));
641
+ a.pet_command(7, "summon", pet, None).unwrap();
642
+ a.players.insert(7, (14.5, -3.5));
643
+ for _ in 0..10 {
644
+ a.step_pets(1.0).unwrap();
645
+ }
646
+ assert!(dist(a.pos(pet).unwrap(), (14.5, -3.5)) <= PET_HEEL + 0.01, "at heel");
647
+ assert_eq!(a.pets_of(7)[0].get("bond"), Some(&json!(1)), "the owner reads the bond");
648
+ let public = a.state().unwrap();
649
+ let shown = public.iter().find(|v| v.get("id") == Some(&json!(pet))).unwrap();
650
+ assert!(!shown.contains_key("bond"), "nobody else does");
651
+ for i in 0..PETS_PER_PLAYER - 1 {
652
+ a.adopt(7, "dog", &format!("Rex{i}")).unwrap();
653
+ }
654
+ let Error(why) = a.adopt(7, "dog", "One too many").unwrap_err();
655
+ assert_eq!(error_code(&why).map(|c| c.0), Some(codes::LIMIT));
656
+ a.pet_command(7, "release", pet, None).unwrap();
657
+ assert_eq!(a.pets_of(7).len(), PETS_PER_PLAYER - 1);
658
+ }
659
+
660
+ #[test]
661
+ fn changes_are_deltas_on_the_area_topic_and_the_snapshot_holds_every_container() {
662
+ let mut hub = TestHub::<AreaHub>::spawn("npcs", "0,0").unwrap();
663
+ hub.answer_node("world.actors_radius", |_| Ok(json!({ "actors": [] })));
664
+ hub.advance(TICK_MS * SCAN_EVERY).unwrap();
665
+ let deltas: Vec<Message<Published>> = hub.published().iter().filter(|(t, _)| t == TOPIC).map(|(_, b)| decode(b).unwrap()).collect();
666
+ assert_eq!(deltas.len(), 8, "every container's first view");
667
+ assert!(deltas.iter().all(|m| m.header.kind == MessageKind::Delta));
668
+ assert!(deltas.iter().all(|m| m.body.props.as_ref().is_some_and(|p| !p.contains_key("home_x"))));
669
+ let snap: Message<Containers> = hub.call_typed(Caller::Player(1), "snapshot", &json!({ "topic": TOPIC })).unwrap();
670
+ assert_eq!((snap.body.containers.len(), snap.header.seq), (8, 8));
671
+ }
672
+
673
+ #[test]
674
+ fn a_snapshot_from_before_the_kit_protocol_loads() {
675
+ let old = encode(&area()).unwrap();
676
+ let hub = TestHub::<AreaHub>::load("npcs", "10,-4", &old, 1).unwrap();
677
+ assert_eq!(hub.hub().area.model.of_type("Mob").count(), MOBS as usize);
678
+ }
469
679
  }
@@ -9,5 +9,5 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.9.0"
12
+ ckx-sdk = "0.10.0"
13
13
  serde = { version = "1", features = ["derive"] }