@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.
- package/README.md +9 -0
- package/crates/ckx-sdk/Cargo.toml +1 -1
- package/crates/ckx-sdk/src/kit/mod.rs +494 -0
- package/crates/ckx-sdk/src/kit/rng.rs +127 -0
- package/crates/ckx-sdk/src/kit/shard.rs +65 -0
- package/crates/ckx-sdk/src/kit/wire.rs +266 -0
- package/crates/ckx-sdk/src/lib.rs +13 -0
- package/crates/ckx-sdk/src/model.rs +5 -0
- package/crates/crowdy-client-sdk/Cargo.toml +1 -1
- package/kit.pin +4 -4
- package/package.json +1 -1
- package/starters/grid-mod/Cargo.toml +1 -1
- package/starters/matchmaker/Cargo.toml +1 -1
- package/starters/matchmaker/src/lib.rs +334 -74
- package/starters/npc-mobs/Cargo.toml +1 -1
- package/starters/npc-mobs/src/lib.rs +277 -67
- package/starters/session/Cargo.toml +1 -1
- package/starters/session/src/lib.rs +191 -86
- package/starters/world-tick/Cargo.toml +1 -1
- package/starters/world-tick/src/lib.rs +151 -38
|
@@ -1,21 +1,31 @@
|
|
|
1
|
-
//! The mobs and
|
|
2
|
-
//! timer and shown to nearby players as server-driven actors. Mobs wander near home, chase
|
|
3
|
-
//! nearest player in range, go home past their leash, take players' hits and respawn; NPCs
|
|
4
|
-
//! walk their routes and talk
|
|
5
|
-
//! modifier, from the root `world` hub's topic.
|
|
6
|
-
//! ([`ckx_sdk::model`]), so players read only their public
|
|
7
|
-
//! `mob-engine` and `npc-engine`
|
|
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)
|
|
10
|
-
//!
|
|
11
|
-
//!
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
292
|
+
return Err(kit::error(codes::COOLDOWN, "attacking too fast"));
|
|
238
293
|
}
|
|
239
|
-
let at = *self.players.get(&player).ok_or_else(||
|
|
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(
|
|
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(
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
328
|
-
if self.model.container(req.npc).is_none_or(|c| c.type_name != "Npc") {
|
|
329
|
-
return Err(
|
|
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
|
-
|
|
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.
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
352
|
-
|
|
353
|
-
self.
|
|
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!(
|
|
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
|
-
|
|
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
|
}
|