@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,469 @@
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.
8
+ //!
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.
12
+
13
+ use std::collections::BTreeMap;
14
+
15
+ use ckx_sdk::prelude::*;
16
+ use serde_json::{Value, json};
17
+
18
+ pub const TICK_MS: u64 = 250;
19
+ /// Ticks between scans of where players are, and between `area` pushes.
20
+ const SCAN_EVERY: u64 = 4;
21
+ const MOBS: u64 = 6;
22
+ /// Distances are in chunks, speeds in chunks a second.
23
+ const AGGRO: f64 = 2.5;
24
+ const LEASH: f64 = 6.0;
25
+ const MOB_SPEED: f64 = 0.5;
26
+ const NPC_SPEED: f64 = 0.25;
27
+ /// How near a player must be to hit a mob.
28
+ const REACH: f64 = 1.5;
29
+ /// The server decides a hit's damage, never the client.
30
+ const HIT: i64 = 10;
31
+ const HIT_COOLDOWN_MS: u64 = 500;
32
+ const RESPAWN_MS: u64 = 10_000;
33
+ /// Players within this many chunks of a mob see it move.
34
+ const SHOW_DISTANCE: u8 = 2;
35
+
36
+ pub fn schema() -> Schema {
37
+ Schema::new()
38
+ .container("Mob", |c| {
39
+ c.prop("kind", PropKind::Str, "wolf", Visibility::Public)
40
+ .prop("hp", PropKind::Int, 100, Visibility::Public)
41
+ .prop("max_hp", PropKind::Int, 100, Visibility::Public)
42
+ .prop("x", PropKind::Float, 0.0, Visibility::Public)
43
+ .prop("z", PropKind::Float, 0.0, Visibility::Public)
44
+ .prop("mode", PropKind::Str, "idle", Visibility::Public)
45
+ .prop("home_x", PropKind::Float, 0.0, Visibility::Hidden)
46
+ .prop("home_z", PropKind::Float, 0.0, Visibility::Hidden)
47
+ .prop("respawn_at", PropKind::Int, 0, Visibility::Hidden)
48
+ })
49
+ .container("Npc", |c| {
50
+ c.prop("name", PropKind::Str, "", Visibility::Public)
51
+ .prop("line", PropKind::Str, "Well met.", Visibility::Public)
52
+ .prop("x", PropKind::Float, 0.0, Visibility::Public)
53
+ .prop("z", PropKind::Float, 0.0, Visibility::Public)
54
+ .prop("route", PropKind::Json, json!([]), Visibility::Hidden)
55
+ .prop("leg", PropKind::Int, 0, Visibility::Hidden)
56
+ })
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>,
67
+ }
68
+
69
+ #[derive(Deserialize)]
70
+ struct Conditions {
71
+ night: bool,
72
+ #[serde(default)]
73
+ modifiers: BTreeMap<String, f64>,
74
+ }
75
+
76
+ #[derive(Deserialize)]
77
+ struct Attack {
78
+ mob: u64,
79
+ }
80
+
81
+ #[derive(Deserialize)]
82
+ struct Talk {
83
+ npc: u64,
84
+ }
85
+
86
+ fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {
87
+ ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
88
+ }
89
+
90
+ /// `from` moved toward `to` by at most `step`.
91
+ fn toward(from: (f64, f64), to: (f64, f64), step: f64) -> (f64, f64) {
92
+ let d = dist(from, to);
93
+ if d <= step || d == 0.0 {
94
+ return to;
95
+ }
96
+ (from.0 + (to.0 - from.0) * step / d, from.1 + (to.1 - from.1) * step / d)
97
+ }
98
+
99
+ #[derive(Serialize, Deserialize)]
100
+ pub struct Area {
101
+ #[serde(skip, default = "schema")]
102
+ schema: Schema,
103
+ pub model: Model,
104
+ pub center: (i64, i64),
105
+ pub ticks: u64,
106
+ pub night: bool,
107
+ pub aggro_scale: f64,
108
+ /// Where each nearby player was at the last scan, in chunks.
109
+ pub players: BTreeMap<u64, (f64, f64)>,
110
+ pub last_hit: BTreeMap<u64, u64>,
111
+ }
112
+
113
+ impl Area {
114
+ 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));
116
+ let mut area = Self {
117
+ schema: schema(),
118
+ model: Model::new(),
119
+ center,
120
+ ticks: 0,
121
+ night: false,
122
+ aggro_scale: 1.0,
123
+ players: BTreeMap::new(),
124
+ last_hit: BTreeMap::new(),
125
+ };
126
+ let (cx, cz) = (center.0 as f64 + 0.5, center.1 as f64 + 0.5);
127
+ for i in 0..MOBS {
128
+ let angle = i as f64 * std::f64::consts::TAU / MOBS as f64;
129
+ let home = (cx + 3.0 * angle.cos(), cz + 3.0 * angle.sin());
130
+ let id = area.model.create(&area.schema, "Mob", None)?;
131
+ for (k, v) in [("x", home.0), ("z", home.1), ("home_x", home.0), ("home_z", home.1)] {
132
+ area.model.set(&area.schema, id, k, v)?;
133
+ }
134
+ }
135
+ for (name, line, route) in [
136
+ (
137
+ "Warden",
138
+ "Mind the wolves after dark.",
139
+ json!([[cx - 2.0, cz - 2.0], [cx + 2.0, cz - 2.0], [cx + 2.0, cz + 2.0], [cx - 2.0, cz + 2.0]]),
140
+ ),
141
+ ("Trader", "Fair prices, mostly.", json!([[cx - 1.0, cz], [cx + 1.0, cz]])),
142
+ ] {
143
+ let id = area.model.create(&area.schema, "Npc", None)?;
144
+ area.model.set(&area.schema, id, "name", name)?;
145
+ area.model.set(&area.schema, id, "line", line)?;
146
+ area.model.set(&area.schema, id, "x", route[0][0].as_f64().unwrap_or(cx))?;
147
+ area.model.set(&area.schema, id, "z", route[0][1].as_f64().unwrap_or(cz))?;
148
+ area.model.set(&area.schema, id, "route", route)?;
149
+ }
150
+ Ok(area)
151
+ }
152
+
153
+ fn pos(&self, id: u64) -> Result<(f64, f64)> {
154
+ Ok((self.model.get_f64(&self.schema, id, "x")?, self.model.get_f64(&self.schema, id, "z")?))
155
+ }
156
+
157
+ fn place(&mut self, id: u64, to: (f64, f64)) -> Result<()> {
158
+ if dist(self.pos(id)?, to) > 0.01 {
159
+ self.model.set(&self.schema, id, "x", to.0)?;
160
+ self.model.set(&self.schema, id, "z", to.1)?;
161
+ }
162
+ Ok(())
163
+ }
164
+
165
+ fn set_mode(&mut self, id: u64, mode: &str) -> Result<()> {
166
+ if self.model.get_str(&self.schema, id, "mode")? != mode {
167
+ self.model.set(&self.schema, id, "mode", mode)?;
168
+ }
169
+ Ok(())
170
+ }
171
+
172
+ pub fn step_mobs(&mut self, now: u64, dt: f64) -> Result<()> {
173
+ let range = AGGRO * self.aggro_scale * if self.night { 1.5 } else { 1.0 };
174
+ let ids: Vec<u64> = self.model.of_type("Mob").collect();
175
+ for id in ids {
176
+ let home = (self.model.get_f64(&self.schema, id, "home_x")?, self.model.get_f64(&self.schema, id, "home_z")?);
177
+ let respawn_at = self.model.get_i64(&self.schema, id, "respawn_at")?;
178
+ if respawn_at > 0 {
179
+ if now < respawn_at as u64 {
180
+ continue;
181
+ }
182
+ let max_hp = self.model.get_i64(&self.schema, id, "max_hp")?;
183
+ self.model.set(&self.schema, id, "hp", max_hp)?;
184
+ self.model.set(&self.schema, id, "respawn_at", 0)?;
185
+ self.place(id, home)?;
186
+ }
187
+ let at = self.pos(id)?;
188
+ let prey = self
189
+ .players
190
+ .values()
191
+ .map(|p| (dist(at, *p), *p))
192
+ .filter(|(d, p)| *d <= range && dist(home, *p) <= LEASH)
193
+ .min_by(|a, b| a.0.total_cmp(&b.0))
194
+ .map(|(_, p)| p);
195
+ let (goal, mode) = if dist(at, home) > LEASH {
196
+ (home, "return")
197
+ } else if let Some(p) = prey {
198
+ (p, "chase")
199
+ } else {
200
+ // Idle mobs circle their home slowly.
201
+ let t = self.ticks as f64 * 0.02 + id as f64;
202
+ ((home.0 + t.cos(), home.1 + t.sin()), "idle")
203
+ };
204
+ self.place(id, toward(at, goal, MOB_SPEED * dt))?;
205
+ self.set_mode(id, mode)?;
206
+ }
207
+ Ok(())
208
+ }
209
+
210
+ pub fn step_npcs(&mut self, dt: f64) -> Result<()> {
211
+ let ids: Vec<u64> = self.model.of_type("Npc").collect();
212
+ for id in ids {
213
+ let route = self.model.get(&self.schema, id, "route")?;
214
+ let points: Vec<(f64, f64)> = route
215
+ .as_array()
216
+ .map(|a| a.iter().filter_map(|p| Some((p.get(0)?.as_f64()?, p.get(1)?.as_f64()?))).collect())
217
+ .unwrap_or_default();
218
+ if points.is_empty() {
219
+ continue;
220
+ }
221
+ let leg = self.model.get_i64(&self.schema, id, "leg")?.rem_euclid(points.len() as i64) as usize;
222
+ let next = toward(self.pos(id)?, points[leg], NPC_SPEED * dt);
223
+ self.place(id, next)?;
224
+ if dist(next, points[leg]) < 0.05 {
225
+ self.model.set(&self.schema, id, "leg", ((leg + 1) % points.len()) as i64)?;
226
+ }
227
+ }
228
+ Ok(())
229
+ }
230
+
231
+ /// A player's hit on a mob: in reach at the last scan, not too fast, fixed damage.
232
+ pub fn attack(&mut self, player: u64, mob: u64, now: u64) -> Result<i64> {
233
+ if self.model.container(mob).is_none_or(|c| c.type_name != "Mob") {
234
+ return Err(Error::new(format!("no mob {mob}")));
235
+ }
236
+ if self.last_hit.get(&player).is_some_and(|at| now < at + HIT_COOLDOWN_MS) {
237
+ return Err(Error::new("attacking too fast"));
238
+ }
239
+ let at = *self.players.get(&player).ok_or_else(|| Error::new("you are not in this area"))?;
240
+ if dist(at, self.pos(mob)?) > REACH {
241
+ return Err(Error::new("out of reach"));
242
+ }
243
+ if self.model.get_i64(&self.schema, mob, "respawn_at")? > 0 {
244
+ return Err(Error::new("that mob is down"));
245
+ }
246
+ self.last_hit.insert(player, now);
247
+ let hp = self.model.add(&self.schema, mob, "hp", -HIT)?;
248
+ if hp <= 0 {
249
+ self.model.set(&self.schema, mob, "hp", 0)?;
250
+ self.model.set(&self.schema, mob, "respawn_at", (now + RESPAWN_MS) as i64)?;
251
+ self.set_mode(mob, "down")?;
252
+ }
253
+ Ok(hp.max(0))
254
+ }
255
+
256
+ pub fn state(&self) -> Result<Vec<BTreeMap<String, Value>>> {
257
+ let ids = self.model.of_type("Mob").chain(self.model.of_type("Npc"));
258
+ ids.map(|id| {
259
+ let mut v = self.model.view(&self.schema, id, Viewer::Public)?;
260
+ v.insert("id".into(), json!(id));
261
+ Ok(v)
262
+ })
263
+ .collect()
264
+ }
265
+
266
+ fn scan(&mut self, ctx: &Ctx) {
267
+ match ctx.world().actors_near(ChunkPos::new(self.center.0, 0, self.center.1), 4, 2) {
268
+ Ok(actors) => {
269
+ self.players =
270
+ actors.into_iter().filter_map(|a| Some((a.player?, (a.chunk.x as f64 + 0.5, a.chunk.z as f64 + 0.5)))).collect();
271
+ }
272
+ Err(e) if self.ticks % (SCAN_EVERY * 60) == 1 => ctx.log(Level::Warn, &format!("scanning for players: {e}")),
273
+ Err(_) => {}
274
+ }
275
+ }
276
+
277
+ fn show(&self, ctx: &Ctx) -> Result<()> {
278
+ for type_name in ["Mob", "Npc"] {
279
+ 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
+ let (x, z) = self.pos(id)?;
289
+ 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)?);
292
+ }
293
+ }
294
+ Ok(())
295
+ }
296
+ }
297
+
298
+ impl Hub for Area {
299
+ fn spawn(ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
300
+ let area = Self::new(&ctx.key)?;
301
+ ctx.timer_every("tick", TICK_MS)?;
302
+ // The root hub's key is empty. Without a `world` type (or leave to call it) the area
303
+ // runs without day and night.
304
+ if let Err(e) = ctx.subscribe("world", "", "world") {
305
+ ctx.log(Level::Info, &format!("not watching the world: {e}"));
306
+ }
307
+ Ok(area)
308
+ }
309
+
310
+ fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
311
+ decode(snapshot)
312
+ }
313
+
314
+ fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
315
+ encode(self)
316
+ }
317
+
318
+ fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
319
+ 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
+ }
325
+ "talk" => {
326
+ 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)));
330
+ }
331
+ encode(&self.model.view(&self.schema, req.npc, Viewer::Public)?)
332
+ }
333
+ other => Err(Error::unknown_method(other)),
334
+ }
335
+ }
336
+
337
+ fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
338
+ if timer != "tick" {
339
+ return Ok(());
340
+ }
341
+ self.ticks += 1;
342
+ if self.ticks % SCAN_EVERY == 1 {
343
+ self.scan(ctx);
344
+ }
345
+ 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)?;
350
+ }
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");
354
+ }
355
+ Ok(())
356
+ }
357
+
358
+ fn on_topic(&mut self, _ctx: &Ctx, msg: TopicMsg<'_>) -> Result<()> {
359
+ if (msg.node_type, msg.topic) == ("world", "world") {
360
+ 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);
363
+ }
364
+ Ok(())
365
+ }
366
+ }
367
+
368
+ ckx_sdk::export_hub!(Area);
369
+
370
+ #[cfg(test)]
371
+ mod tests {
372
+ use super::*;
373
+
374
+ fn area() -> Area {
375
+ Area::new("10,-4").unwrap()
376
+ }
377
+
378
+ fn first_mob(a: &Area) -> u64 {
379
+ a.model.of_type("Mob").next().unwrap()
380
+ }
381
+
382
+ #[test]
383
+ fn the_key_is_the_centre_chunk_and_the_area_is_populated() {
384
+ let a = area();
385
+ assert_eq!(a.center, (10, -4));
386
+ assert_eq!(a.model.of_type("Mob").count(), MOBS as usize);
387
+ assert_eq!(a.model.of_type("Npc").count(), 2);
388
+ assert_eq!(Area::new("not a key").unwrap().center, (0, 0));
389
+ }
390
+
391
+ #[test]
392
+ fn a_mob_chases_a_near_player_and_goes_home_past_its_leash() {
393
+ let mut a = area();
394
+ let m = first_mob(&a);
395
+ let at = a.pos(m).unwrap();
396
+ a.players.insert(7, (at.0 + 1.0, at.1));
397
+ a.step_mobs(0, 1.0).unwrap();
398
+ assert_eq!(a.model.get_str(&a.schema, m, "mode").unwrap(), "chase");
399
+ assert!(dist(a.pos(m).unwrap(), (at.0 + 1.0, at.1)) < 0.6);
400
+ a.players.clear();
401
+ a.place(m, (at.0 + LEASH + 5.0, at.1)).unwrap();
402
+ a.step_mobs(0, 1.0).unwrap();
403
+ assert_eq!(a.model.get_str(&a.schema, m, "mode").unwrap(), "return");
404
+ }
405
+
406
+ #[test]
407
+ fn night_and_an_aggro_event_widen_the_range() {
408
+ let mut a = area();
409
+ let m = first_mob(&a);
410
+ let at = a.pos(m).unwrap();
411
+ a.players.insert(7, (at.0 + AGGRO + 0.5, at.1));
412
+ a.step_mobs(0, 0.0).unwrap();
413
+ assert_eq!(a.model.get_str(&a.schema, m, "mode").unwrap(), "idle");
414
+ a.night = true;
415
+ a.step_mobs(0, 0.0).unwrap();
416
+ assert_eq!(a.model.get_str(&a.schema, m, "mode").unwrap(), "chase");
417
+ }
418
+
419
+ #[test]
420
+ fn hits_need_reach_respect_the_cooldown_and_a_downed_mob_respawns() {
421
+ let mut a = area();
422
+ let m = first_mob(&a);
423
+ assert!(a.attack(7, m, 0).is_err(), "not in the area");
424
+ let at = a.pos(m).unwrap();
425
+ a.players.insert(7, (at.0 + REACH + 1.0, at.1));
426
+ assert!(a.attack(7, m, 0).is_err(), "out of reach");
427
+ a.players.insert(7, at);
428
+ assert_eq!(a.attack(7, m, 0).unwrap(), 90);
429
+ assert!(a.attack(7, m, 100).is_err(), "cooldown");
430
+ let mut now = 0;
431
+ while a.model.get_i64(&a.schema, m, "hp").unwrap() > 0 {
432
+ now += HIT_COOLDOWN_MS;
433
+ a.attack(7, m, now).unwrap();
434
+ }
435
+ assert!(a.attack(7, m, now + HIT_COOLDOWN_MS).is_err(), "down");
436
+ a.step_mobs(now + RESPAWN_MS, 0.0).unwrap();
437
+ assert_eq!(a.model.get_i64(&a.schema, m, "hp").unwrap(), 100);
438
+ let npc = a.model.of_type("Npc").next().unwrap();
439
+ assert!(a.attack(7, npc, now + RESPAWN_MS).is_err(), "not a mob");
440
+ }
441
+
442
+ #[test]
443
+ fn npcs_walk_their_route_and_players_see_only_public_properties() {
444
+ let mut a = area();
445
+ let warden = a.model.of_type("Npc").next().unwrap();
446
+ let route: Vec<(f64, f64)> = a
447
+ .model
448
+ .get(&a.schema, warden, "route")
449
+ .unwrap()
450
+ .as_array()
451
+ .unwrap()
452
+ .iter()
453
+ .map(|p| (p[0].as_f64().unwrap(), p[1].as_f64().unwrap()))
454
+ .collect();
455
+ let mut reached = vec![false; route.len()];
456
+ // 50 chunks of walking: three times round a 16-chunk loop.
457
+ for _ in 0..200 {
458
+ a.step_npcs(1.0).unwrap();
459
+ let at = a.pos(warden).unwrap();
460
+ for (i, corner) in route.iter().enumerate() {
461
+ reached[i] |= dist(at, *corner) < 0.05;
462
+ }
463
+ }
464
+ assert_eq!(reached, vec![true; 4]);
465
+ let views = a.state().unwrap();
466
+ assert!(views.iter().all(|v| !v.contains_key("route") && !v.contains_key("home_x") && !v.contains_key("respawn_at")));
467
+ assert!(views.iter().any(|v| v.get("name") == Some(&json!("Warden"))));
468
+ }
469
+ }
@@ -0,0 +1,13 @@
1
+ [package]
2
+ name = "session"
3
+ description = "Starter: one game session from lobby to result, with a host, ready checks, turns and scores"
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"] }