@crowdedkingdoms/ckx-kit 0.3.0-dev.1 → 0.5.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 (68) hide show
  1. package/README.md +9 -2
  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/econ/holdings.rs +241 -0
  10. package/crates/ckx-sdk/src/kit/econ/holds.rs +202 -0
  11. package/crates/ckx-sdk/src/kit/econ/items.rs +212 -0
  12. package/crates/ckx-sdk/src/kit/econ/loot.rs +116 -0
  13. package/crates/ckx-sdk/src/kit/econ/mod.rs +50 -0
  14. package/crates/ckx-sdk/src/kit/econ/orderbook.rs +155 -0
  15. package/crates/ckx-sdk/src/kit/econ/production.rs +155 -0
  16. package/crates/ckx-sdk/src/kit/econ/progression.rs +254 -0
  17. package/crates/ckx-sdk/src/kit/econ/recipes.rs +65 -0
  18. package/crates/ckx-sdk/src/kit/econ/vendor.rs +116 -0
  19. package/crates/ckx-sdk/src/kit/econ/wallet.rs +67 -0
  20. package/crates/ckx-sdk/src/kit/mod.rs +72 -3
  21. package/crates/ckx-sdk/src/kit/play/abilities.rs +370 -0
  22. package/crates/ckx-sdk/src/kit/play/cards.rs +220 -0
  23. package/crates/ckx-sdk/src/kit/play/combat.rs +292 -0
  24. package/crates/ckx-sdk/src/kit/play/mod.rs +15 -0
  25. package/crates/ckx-sdk/src/kit/play/movement.rs +196 -0
  26. package/crates/ckx-sdk/src/kit/play/score.rs +183 -0
  27. package/crates/ckx-sdk/src/kit/play/timing.rs +218 -0
  28. package/crates/ckx-sdk/src/kit/play/turns.rs +315 -0
  29. package/crates/ckx-sdk/src/kit/presence.rs +142 -0
  30. package/crates/ckx-sdk/src/kit/rng.rs +10 -0
  31. package/crates/ckx-sdk/src/kit/sim/daycycle.rs +71 -0
  32. package/crates/ckx-sdk/src/kit/sim/mod.rs +10 -0
  33. package/crates/ckx-sdk/src/kit/sim/nodes.rs +109 -0
  34. package/crates/ckx-sdk/src/kit/sim/waves.rs +105 -0
  35. package/crates/ckx-sdk/src/kit/sim/weather.rs +142 -0
  36. package/crates/ckx-sdk/src/kit/sim/zones.rs +218 -0
  37. package/crates/ckx-sdk/src/lib.rs +2 -1
  38. package/dist/cli/src/new.js +11 -0
  39. package/kit.pin +4 -4
  40. package/package.json +1 -1
  41. package/starters/arena/Cargo.toml +14 -0
  42. package/starters/arena/src/lib.rs +849 -0
  43. package/starters/ckx.json +14 -1
  44. package/starters/deck/Cargo.toml +14 -0
  45. package/starters/deck/src/lib.rs +510 -0
  46. package/starters/director/Cargo.toml +14 -0
  47. package/starters/director/src/lib.rs +628 -0
  48. package/starters/grid-mod/Cargo.toml +1 -1
  49. package/starters/instance/Cargo.toml +14 -0
  50. package/starters/instance/src/lib.rs +656 -0
  51. package/starters/leaderboard/Cargo.toml +14 -0
  52. package/starters/leaderboard/src/lib.rs +395 -0
  53. package/starters/market/Cargo.toml +14 -0
  54. package/starters/market/src/lib.rs +1137 -0
  55. package/starters/matchmaker/Cargo.toml +1 -1
  56. package/starters/minigame/Cargo.toml +14 -0
  57. package/starters/minigame/src/lib.rs +391 -0
  58. package/starters/npc-mobs/Cargo.toml +1 -1
  59. package/starters/player/Cargo.toml +14 -0
  60. package/starters/player/src/lib.rs +994 -0
  61. package/starters/racing/Cargo.toml +14 -0
  62. package/starters/racing/src/lib.rs +1151 -0
  63. package/starters/session/Cargo.toml +1 -1
  64. package/starters/tactics/Cargo.toml +14 -0
  65. package/starters/tactics/src/lib.rs +562 -0
  66. package/starters/territory/Cargo.toml +14 -0
  67. package/starters/territory/src/lib.rs +878 -0
  68. package/starters/world-tick/Cargo.toml +1 -1
package/README.md CHANGED
@@ -37,7 +37,7 @@ on the platform: Crowdy Studio, or `execModBuild` then `execModDeploy` (see
37
37
 
38
38
  | Command | What it does |
39
39
  |---|---|
40
- | `ckx-kit new <template> <name>` | A crate to start from: `mod`, `client` (a mod's CLIENT half), `hub`, `spoke`, or a starter pack crate (`world-tick`, `matchmaker`, `session`, `npc-mobs`). |
40
+ | `ckx-kit new <template> <name>` | A crate to start from: `mod`, `client` (a mod's CLIENT half), `hub`, `spoke`, or a starter pack crate (`world-tick`, `matchmaker`, `session`, `npc-mobs`, `leaderboard`, `deck`, `tactics`, `director`, `minigame`, `instance`, `arena`, `racing`, `territory`, `player`, `market`). |
41
41
  | `ckx-kit check [dir]` | The platform's source checks, on exactly the files it takes (`Cargo.toml`, `README.md`, `.rs` files under `src/`). Lists what it would not send. |
42
42
  | `ckx-kit build [dir]` | The checks, then the pinned Rust for `wasm32-unknown-unknown` with the platform's release profile and the platform's dependency versions, then the module check. `--mod` adds the mod limits. A CLIENT half is metered and optimized as the platform does it, and its capability summary, the list visitors are asked to agree to, is printed with its hash. `--json` prints a report. |
43
43
  | `ckx-kit doctor` | What a build here needs, and which platform toolchain this kit matches. |
@@ -52,7 +52,14 @@ protocol a kit hub speaks to the kit clients in CrowdyJS, CrowdyCPP and CrowdyPy
52
52
  that carry a sequence number and resync after a gap, actions a client may resend with an
53
53
  `action_id` that the hub answers once, refusals with a `kit:<code>:` code), shard keys, a seeded
54
54
  random generator and the realtime event registry. The starter pack crates speak it, so the kit
55
- clients' typed modules drive them as they are.
55
+ clients' typed modules drive them as they are. From `ckx-sdk` 0.11.0 it also has the pieces the
56
+ genre starters are made of: `kit::play` (turns, scores, cards, combat, abilities, timing, a
57
+ movement warden for positions clients report), `kit::sim` (day cycle, weather, resource nodes,
58
+ waves, zones) and `kit::ai` (paths, flow fields, steering, behavior trees); from 0.12.0
59
+ `kit::econ`, the soft economy the `player` and `market` starters keep in hub state: items,
60
+ wallets, holds, references answered once, recipes, loot with pity, production, vendors, an
61
+ order book, levels, achievements and quests. It is not a ledger: no real money, and a transfer
62
+ between hubs is steps, not a transaction.
56
63
 
57
64
  ## Test
58
65
 
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ckx-sdk"
3
- version = "0.10.0-dev.1"
3
+ version = "0.12.1-dev.1"
4
4
  edition = "2024"
5
5
  description = "Write ck-exec hubs, spokes and mods for Crowded Kingdoms in Rust, compiled to wasm32-unknown-unknown"
6
6
  license = "MIT OR Apache-2.0"
@@ -0,0 +1,288 @@
1
+ //! Data-driven agent behavior: a small behavior-tree interpreter whose trees
2
+ //! live as JSON (in a hub's state or its seed), so designers
3
+ //! iterate without recompiling Rust.
4
+ //!
5
+ //! Node shapes (JSON):
6
+ //! { "type": "sequence", "children": [...] }
7
+ //! { "type": "selector", "children": [...] }
8
+ //! { "type": "condition", "if": "<sensor>", "then": {...} } sensors below
9
+ //! { "type": "idle" }
10
+ //! { "type": "wander", "range": 12.0, "retarget_ms": 6000 }
11
+ //! { "type": "follow", "target": "owner"|"nearest_player", "stop_at": 2.0 }
12
+ //! { "type": "chase", "range": 14.0, "stop_at": 1.1 }
13
+ //! { "type": "flee", "range": 8.0 }
14
+ //! { "type": "patrol", "points": [[x,z], ...], "pause_ms": 2000 }
15
+ //!
16
+ //! Sensors for `condition.if`: "player_near" (within `range`, default 10),
17
+ //! "is_night", "is_day".
18
+ //!
19
+ //! The interpreter never moves the agent itself: it returns a [`Decision`]
20
+ //! the hub turns into steering and emits. Ticks are bounded (tree depth
21
+ //! caps at 8, first-decision wins), so fuel stays predictable.
22
+
23
+ use super::steering::{self, Vec2};
24
+ use serde::{Deserialize, Serialize};
25
+ use serde_json::Value;
26
+
27
+ /// What the agent decided to do this tick.
28
+ #[derive(Debug, Clone, PartialEq)]
29
+ pub enum Decision {
30
+ Idle,
31
+ /// Move by this bounded step.
32
+ Step(Vec2),
33
+ /// Nothing matched / tree invalid — caller may fall back.
34
+ None,
35
+ }
36
+
37
+ /// Everything a tree evaluation may look at (the hub fills this in).
38
+ pub struct Perception {
39
+ pub pos: Vec2,
40
+ pub home: Vec2,
41
+ /// Nearest player position, when one is visible.
42
+ pub nearest_player: Option<Vec2>,
43
+ /// Owner position (pets), when known.
44
+ pub owner: Option<Vec2>,
45
+ pub is_night: bool,
46
+ pub speed: f32,
47
+ pub dt: f32,
48
+ pub now_ms: u64,
49
+ /// Two uniform rolls in [0,1) for this tick (from the hub's seeded RNG).
50
+ pub roll: (f32, f32),
51
+ /// World half-extent for wander clamping.
52
+ pub bounds: f32,
53
+ }
54
+
55
+ /// Per-agent mutable memory the tree reads/writes across ticks (serde-able
56
+ /// into the hub's state).
57
+ #[derive(Serialize, Deserialize, Clone, Debug, Default)]
58
+ pub struct AgentMemory {
59
+ pub target: Option<(f32, f32)>,
60
+ pub retarget_at_ms: u64,
61
+ pub patrol_index: usize,
62
+ pub pause_until_ms: u64,
63
+ }
64
+
65
+ const MAX_DEPTH: usize = 8;
66
+
67
+ pub fn evaluate(tree: &Value, p: &Perception, memory: &mut AgentMemory) -> Decision {
68
+ eval_node(tree, p, memory, 0)
69
+ }
70
+
71
+ fn eval_node(node: &Value, p: &Perception, m: &mut AgentMemory, depth: usize) -> Decision {
72
+ if depth > MAX_DEPTH {
73
+ return Decision::None;
74
+ }
75
+ match node["type"].as_str().unwrap_or("") {
76
+ "sequence" | "selector" => {
77
+ // First child returning a real decision wins (bounded work).
78
+ for child in node["children"].as_array().unwrap_or(&Vec::new()) {
79
+ match eval_node(child, p, m, depth + 1) {
80
+ Decision::None => continue,
81
+ decision => return decision,
82
+ }
83
+ }
84
+ Decision::None
85
+ }
86
+ "condition" => {
87
+ if sense(node, p) {
88
+ eval_node(&node["then"], p, m, depth + 1)
89
+ } else {
90
+ Decision::None
91
+ }
92
+ }
93
+ "idle" => Decision::Idle,
94
+ "wander" => {
95
+ let range = node["range"].as_f64().unwrap_or(12.0) as f32;
96
+ let retarget_ms = node["retarget_ms"].as_u64().unwrap_or(6_000);
97
+ if m.target.is_none() || p.now_ms >= m.retarget_at_ms {
98
+ let t = steering::wander_target(p.home, range, p.roll.0, p.roll.1, p.bounds);
99
+ m.target = Some((t.x, t.z));
100
+ m.retarget_at_ms = p.now_ms + retarget_ms;
101
+ }
102
+ let (tx, tz) = m.target.unwrap();
103
+ Decision::Step(steering::seek(p.pos, Vec2::new(tx, tz), p.speed, p.dt, 0.4))
104
+ }
105
+ "follow" => {
106
+ let target = match node["target"].as_str().unwrap_or("nearest_player") {
107
+ "owner" => p.owner,
108
+ _ => p.nearest_player,
109
+ };
110
+ let stop_at = node["stop_at"].as_f64().unwrap_or(2.0) as f32;
111
+ match target {
112
+ Some(t) => Decision::Step(steering::arrive(p.pos, t, p.speed, p.dt, stop_at * 2.0)),
113
+ None => Decision::None,
114
+ }
115
+ }
116
+ "chase" => {
117
+ let range = node["range"].as_f64().unwrap_or(14.0) as f32;
118
+ let stop_at = node["stop_at"].as_f64().unwrap_or(1.1) as f32;
119
+ match p.nearest_player {
120
+ Some(t) if p.pos.dist(t) <= range => Decision::Step(steering::seek(p.pos, t, p.speed, p.dt, stop_at)),
121
+ _ => Decision::None,
122
+ }
123
+ }
124
+ "flee" => {
125
+ let range = node["range"].as_f64().unwrap_or(8.0) as f32;
126
+ match p.nearest_player {
127
+ Some(t) if p.pos.dist(t) <= range => Decision::Step(steering::flee(p.pos, t, p.speed, p.dt)),
128
+ _ => Decision::None,
129
+ }
130
+ }
131
+ "patrol" => {
132
+ let points: Vec<Vec2> = node["points"]
133
+ .as_array()
134
+ .unwrap_or(&Vec::new())
135
+ .iter()
136
+ .filter_map(|pt| Some(Vec2::new(pt[0].as_f64()? as f32, pt[1].as_f64()? as f32)))
137
+ .collect();
138
+ if points.is_empty() {
139
+ return Decision::None;
140
+ }
141
+ if p.now_ms < m.pause_until_ms {
142
+ return Decision::Idle;
143
+ }
144
+ let target = points[m.patrol_index % points.len()];
145
+ if p.pos.dist(target) <= 0.5 {
146
+ m.patrol_index = (m.patrol_index + 1) % points.len();
147
+ m.pause_until_ms = p.now_ms + node["pause_ms"].as_u64().unwrap_or(2_000);
148
+ return Decision::Idle;
149
+ }
150
+ Decision::Step(steering::seek(p.pos, target, p.speed, p.dt, 0.3))
151
+ }
152
+ _ => Decision::None,
153
+ }
154
+ }
155
+
156
+ fn sense(node: &Value, p: &Perception) -> bool {
157
+ match node["if"].as_str().unwrap_or("") {
158
+ "player_near" => {
159
+ let range = node["range"].as_f64().unwrap_or(10.0) as f32;
160
+ p.nearest_player.map(|t| p.pos.dist(t) <= range).unwrap_or(false)
161
+ }
162
+ "is_night" => p.is_night,
163
+ "is_day" => !p.is_night,
164
+ _ => false,
165
+ }
166
+ }
167
+
168
+ /// Canonical trees for the built-in behavior names a game's agent definitions use
169
+ /// (`wander_passive`, `wander_hostile`, `chase_hostile`).
170
+ pub fn builtin_tree(name: &str) -> Option<Value> {
171
+ let tree = match name {
172
+ "wander_passive" => serde_json::json!({
173
+ "type": "selector",
174
+ "children": [
175
+ { "type": "condition", "if": "player_near", "range": 2.5,
176
+ "then": { "type": "flee", "range": 2.5 } },
177
+ { "type": "wander", "range": 12.0 }
178
+ ]
179
+ }),
180
+ "wander_hostile" | "chase_hostile" => serde_json::json!({
181
+ "type": "selector",
182
+ "children": [
183
+ { "type": "chase", "range": 14.0, "stop_at": 1.1 },
184
+ { "type": "wander", "range": 12.0 }
185
+ ]
186
+ }),
187
+ "wander" => serde_json::json!({ "type": "wander", "range": 12.0 }),
188
+ "follow_owner" => serde_json::json!({
189
+ "type": "selector",
190
+ "children": [
191
+ { "type": "follow", "target": "owner", "stop_at": 2.0 },
192
+ { "type": "wander", "range": 4.0 }
193
+ ]
194
+ }),
195
+ _ => return None,
196
+ };
197
+ Some(tree)
198
+ }
199
+
200
+ #[cfg(test)]
201
+ mod tests {
202
+ use super::*;
203
+
204
+ fn perception(pos: (f32, f32), player: Option<(f32, f32)>) -> Perception {
205
+ Perception {
206
+ pos: Vec2::new(pos.0, pos.1),
207
+ home: Vec2::default(),
208
+ nearest_player: player.map(|(x, z)| Vec2::new(x, z)),
209
+ owner: None,
210
+ is_night: false,
211
+ speed: 2.0,
212
+ dt: 0.25,
213
+ now_ms: 10_000,
214
+ roll: (0.9, 0.1),
215
+ bounds: 40.0,
216
+ }
217
+ }
218
+
219
+ #[test]
220
+ fn hostile_chases_then_wanders() {
221
+ let tree = builtin_tree("chase_hostile").unwrap();
222
+ let mut memory = AgentMemory::default();
223
+ // Player in aggro range: chase step points at the player.
224
+ let d = evaluate(&tree, &perception((0.0, 0.0), Some((5.0, 0.0))), &mut memory);
225
+ match d {
226
+ Decision::Step(step) => assert!(step.x > 0.0),
227
+ other => panic!("expected chase step, got {other:?}"),
228
+ }
229
+ // No player: falls through to wander (sets a target).
230
+ let d = evaluate(&tree, &perception((0.0, 0.0), None), &mut memory);
231
+ assert!(matches!(d, Decision::Step(_)));
232
+ assert!(memory.target.is_some());
233
+ }
234
+
235
+ #[test]
236
+ fn passive_flees_close_players() {
237
+ let tree = builtin_tree("wander_passive").unwrap();
238
+ let mut memory = AgentMemory::default();
239
+ let d = evaluate(&tree, &perception((0.0, 0.0), Some((1.0, 0.0))), &mut memory);
240
+ match d {
241
+ Decision::Step(step) => assert!(step.x < 0.0, "flees away"),
242
+ other => panic!("expected flee, got {other:?}"),
243
+ }
244
+ }
245
+
246
+ #[test]
247
+ fn patrol_cycles_points() {
248
+ let tree = serde_json::json!({
249
+ "type": "patrol", "points": [[0.0, 0.0], [10.0, 0.0]], "pause_ms": 1000
250
+ });
251
+ let mut memory = AgentMemory::default();
252
+ // Standing on point 0: advances the index and pauses.
253
+ let d = evaluate(&tree, &perception((0.0, 0.0), None), &mut memory);
254
+ assert_eq!(d, Decision::Idle);
255
+ assert_eq!(memory.patrol_index, 1);
256
+ // After the pause: walks toward point 1.
257
+ let mut p = perception((0.0, 0.0), None);
258
+ p.now_ms = 20_000;
259
+ match evaluate(&tree, &p, &mut memory) {
260
+ Decision::Step(step) => assert!(step.x > 0.0),
261
+ other => panic!("expected step, got {other:?}"),
262
+ }
263
+ }
264
+
265
+ #[test]
266
+ fn depth_cap_and_unknown_nodes() {
267
+ // Deeply nested selector chain past the cap yields None.
268
+ let mut node = serde_json::json!({ "type": "idle" });
269
+ for _ in 0..12 {
270
+ node = serde_json::json!({ "type": "selector", "children": [node] });
271
+ }
272
+ let mut memory = AgentMemory::default();
273
+ assert_eq!(evaluate(&node, &perception((0.0, 0.0), None), &mut memory), Decision::None);
274
+ assert_eq!(evaluate(&serde_json::json!({"type": "nope"}), &perception((0.0, 0.0), None), &mut memory), Decision::None);
275
+ }
276
+
277
+ #[test]
278
+ fn follow_owner_tree() {
279
+ let tree = builtin_tree("follow_owner").unwrap();
280
+ let mut memory = AgentMemory::default();
281
+ let mut p = perception((0.0, 0.0), None);
282
+ p.owner = Some(Vec2::new(8.0, 0.0));
283
+ match evaluate(&tree, &p, &mut memory) {
284
+ Decision::Step(step) => assert!(step.x > 0.0),
285
+ other => panic!("expected follow step, got {other:?}"),
286
+ }
287
+ }
288
+ }
@@ -0,0 +1,163 @@
1
+ //! Flow fields: one breadth-first distance field from the goal(s), then any
2
+ //! number of agents descend it with O(1) lookups per step — the tower-defense
3
+ //! creep mover (many agents, one goal, shared computation). Rebuild the field
4
+ //! only when the world changes (tower placed); agents just follow.
5
+
6
+ use super::path::CostProvider;
7
+ use serde::{Deserialize, Serialize};
8
+ use std::collections::VecDeque;
9
+
10
+ /// A baked distance field over a bounded grid window. Serde-able so hubs
11
+ /// can persist it, but rebuilding from the provider is usually cheaper than
12
+ /// storing it durably.
13
+ #[derive(Serialize, Deserialize, Clone, Debug)]
14
+ pub struct FlowField {
15
+ pub min_x: i32,
16
+ pub min_z: i32,
17
+ pub width: i32,
18
+ pub height: i32,
19
+ /// Distance to the nearest goal per cell; u32::MAX = unreachable/blocked.
20
+ distances: Vec<u32>,
21
+ }
22
+
23
+ impl FlowField {
24
+ /// Build the field inside the window from the goal cells outward,
25
+ /// respecting the provider's blocked cells. Cost = uniform 1 per step
26
+ /// (weighted providers still gate walkability; weighting the descent is
27
+ /// deliberately v2). `max_cells` caps the BFS for fuel safety.
28
+ pub fn build(
29
+ provider: &impl CostProvider,
30
+ window: (i32, i32, i32, i32), // min_x, min_z, width, height
31
+ goals: &[(i32, i32)],
32
+ max_cells: usize,
33
+ ) -> Self {
34
+ let (min_x, min_z, width, height) = window;
35
+ let mut field = Self { min_x, min_z, width, height, distances: vec![u32::MAX; (width * height).max(0) as usize] };
36
+ let mut queue: VecDeque<(i32, i32)> = VecDeque::new();
37
+ for &(gx, gz) in goals {
38
+ if let Some(index) = field.index(gx, gz)
39
+ && provider.cost(gx, gz).is_some()
40
+ {
41
+ field.distances[index] = 0;
42
+ queue.push_back((gx, gz));
43
+ }
44
+ }
45
+ let mut visited = 0usize;
46
+ while let Some((x, z)) = queue.pop_front() {
47
+ visited += 1;
48
+ if visited > max_cells {
49
+ break;
50
+ }
51
+ let here = field.distance(x, z).unwrap_or(u32::MAX);
52
+ for (dx, dz) in [(0, 1), (0, -1), (1, 0), (-1, 0)] {
53
+ let (nx, nz) = (x + dx, z + dz);
54
+ let Some(index) = field.index(nx, nz) else { continue };
55
+ if provider.cost(nx, nz).is_none() {
56
+ continue;
57
+ }
58
+ if field.distances[index] > here.saturating_add(1) {
59
+ field.distances[index] = here + 1;
60
+ queue.push_back((nx, nz));
61
+ }
62
+ }
63
+ }
64
+ field
65
+ }
66
+
67
+ fn index(&self, x: i32, z: i32) -> Option<usize> {
68
+ let (dx, dz) = (x - self.min_x, z - self.min_z);
69
+ if dx < 0 || dz < 0 || dx >= self.width || dz >= self.height {
70
+ return None;
71
+ }
72
+ Some((dz * self.width + dx) as usize)
73
+ }
74
+
75
+ /// Distance to the nearest goal (None outside the window or unreachable).
76
+ pub fn distance(&self, x: i32, z: i32) -> Option<u32> {
77
+ let d = self.distances[self.index(x, z)?];
78
+ (d != u32::MAX).then_some(d)
79
+ }
80
+
81
+ /// The descending step from (x, z): the 4-neighbor with the smallest
82
+ /// distance, when strictly smaller than here. None at goals/unreachable.
83
+ pub fn step(&self, x: i32, z: i32) -> Option<(i32, i32)> {
84
+ let here = self.distance(x, z)?;
85
+ if here == 0 {
86
+ return None; // at a goal
87
+ }
88
+ let mut best: Option<((i32, i32), u32)> = None;
89
+ for (dx, dz) in [(0, 1), (0, -1), (1, 0), (-1, 0)] {
90
+ let (nx, nz) = (x + dx, z + dz);
91
+ if let Some(d) = self.distance(nx, nz)
92
+ && d < here
93
+ && best.is_none_or(|(_, bd)| d < bd)
94
+ {
95
+ best = Some(((dx, dz), d));
96
+ }
97
+ }
98
+ best.map(|(step, _)| step)
99
+ }
100
+
101
+ /// Follow the field from (x, z) for up to `max_steps`, returning the
102
+ /// walked cells (excluding the start). Agents usually take 1 per tick;
103
+ /// this helper powers tests and catch-up moves.
104
+ pub fn walk(&self, mut x: i32, mut z: i32, max_steps: usize) -> Vec<(i32, i32)> {
105
+ let mut path = Vec::new();
106
+ for _ in 0..max_steps {
107
+ let Some((dx, dz)) = self.step(x, z) else { break };
108
+ x += dx;
109
+ z += dz;
110
+ path.push((x, z));
111
+ }
112
+ path
113
+ }
114
+ }
115
+
116
+ #[cfg(test)]
117
+ mod tests {
118
+ use super::*;
119
+ use crate::kit::ai::path::GridProvider;
120
+
121
+ fn provider<'a>(blocked: &'a dyn Fn(i32, i32) -> bool) -> GridProvider<'a> {
122
+ GridProvider { width: 32, height: 32, blocked }
123
+ }
124
+
125
+ #[test]
126
+ fn field_descends_to_goal() {
127
+ let open = |_x: i32, _z: i32| false;
128
+ let field = FlowField::build(&provider(&open), (0, 0, 32, 32), &[(31, 16)], 100_000);
129
+ assert_eq!(field.distance(31, 16), Some(0));
130
+ assert_eq!(field.distance(0, 16), Some(31));
131
+ // Walking from anywhere reaches the goal in exactly distance steps.
132
+ let path = field.walk(0, 16, 100);
133
+ assert_eq!(path.len(), 31);
134
+ assert_eq!(*path.last().unwrap(), (31, 16));
135
+ }
136
+
137
+ #[test]
138
+ fn field_routes_around_walls_and_marks_unreachable() {
139
+ // Wall at x=10 with a gap at z=0.
140
+ let wall = |x: i32, z: i32| x == 10 && z != 0;
141
+ let field = FlowField::build(&provider(&wall), (0, 0, 32, 32), &[(31, 16)], 100_000);
142
+ let path = field.walk(0, 16, 200);
143
+ assert_eq!(*path.last().unwrap(), (31, 16));
144
+ assert!(path.contains(&(10, 0)), "routes through the gap");
145
+ // Fully sealed column: left side unreachable.
146
+ let sealed = |x: i32, _z: i32| x == 10;
147
+ let field = FlowField::build(&provider(&sealed), (0, 0, 32, 32), &[(31, 16)], 100_000);
148
+ assert_eq!(field.distance(0, 16), None);
149
+ assert!(field.walk(0, 16, 10).is_empty());
150
+ }
151
+
152
+ #[test]
153
+ fn multiple_goals_pick_nearest() {
154
+ let open = |_x: i32, _z: i32| false;
155
+ let field = FlowField::build(&provider(&open), (0, 0, 32, 32), &[(0, 0), (31, 31)], 100_000);
156
+ assert_eq!(field.distance(2, 2), Some(4));
157
+ assert_eq!(field.distance(29, 30), Some(3));
158
+ // Descent from the middle goes to ONE of the goals.
159
+ let path = field.walk(16, 16, 200);
160
+ let end = *path.last().unwrap();
161
+ assert!(end == (0, 0) || end == (31, 31));
162
+ }
163
+ }
@@ -0,0 +1,10 @@
1
+ //! Agent brains for hubs that move things: [`path`] (A* with a step budget), [`flow`] (one
2
+ //! distance field many agents descend), [`steering`] (seek, flee, arrive, wander, separate),
3
+ //! [`brain`] (behavior trees as JSON) and [`turn`] (budgeted movers for turn-based enemies).
4
+ //! Every search is capped, so one bad query cannot use up a call's fuel.
5
+
6
+ pub mod brain;
7
+ pub mod flow;
8
+ pub mod path;
9
+ pub mod steering;
10
+ pub mod turn;