@crowdedkingdoms/ckx-kit 0.3.0-dev.1 → 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 (52) hide show
  1. package/README.md +5 -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/mod.rs +49 -1
  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 +10 -0
  20. package/crates/ckx-sdk/src/kit/sim/daycycle.rs +71 -0
  21. package/crates/ckx-sdk/src/kit/sim/mod.rs +10 -0
  22. package/crates/ckx-sdk/src/kit/sim/nodes.rs +109 -0
  23. package/crates/ckx-sdk/src/kit/sim/waves.rs +105 -0
  24. package/crates/ckx-sdk/src/kit/sim/weather.rs +142 -0
  25. package/crates/ckx-sdk/src/kit/sim/zones.rs +218 -0
  26. package/dist/cli/src/new.js +9 -0
  27. package/kit.pin +4 -4
  28. package/package.json +1 -1
  29. package/starters/arena/Cargo.toml +14 -0
  30. package/starters/arena/src/lib.rs +849 -0
  31. package/starters/ckx.json +12 -1
  32. package/starters/deck/Cargo.toml +14 -0
  33. package/starters/deck/src/lib.rs +510 -0
  34. package/starters/director/Cargo.toml +14 -0
  35. package/starters/director/src/lib.rs +628 -0
  36. package/starters/grid-mod/Cargo.toml +1 -1
  37. package/starters/instance/Cargo.toml +14 -0
  38. package/starters/instance/src/lib.rs +656 -0
  39. package/starters/leaderboard/Cargo.toml +14 -0
  40. package/starters/leaderboard/src/lib.rs +395 -0
  41. package/starters/matchmaker/Cargo.toml +1 -1
  42. package/starters/minigame/Cargo.toml +14 -0
  43. package/starters/minigame/src/lib.rs +391 -0
  44. package/starters/npc-mobs/Cargo.toml +1 -1
  45. package/starters/racing/Cargo.toml +14 -0
  46. package/starters/racing/src/lib.rs +1151 -0
  47. package/starters/session/Cargo.toml +1 -1
  48. package/starters/tactics/Cargo.toml +14 -0
  49. package/starters/tactics/src/lib.rs +562 -0
  50. package/starters/territory/Cargo.toml +14 -0
  51. package/starters/territory/src/lib.rs +878 -0
  52. package/starters/world-tick/Cargo.toml +1 -1
@@ -0,0 +1,214 @@
1
+ //! Budget-capped A* over a pluggable cost provider. Under the platform's fuel
2
+ //! metering A* costs a little more than natively; the budget cap keeps a
3
+ //! pathological query from eating a call's fuel.
4
+
5
+ /// Walkability/cost source. Implement over your world representation
6
+ /// (obstacle grids, voxel columns, nav data in a hub's state).
7
+ pub trait CostProvider {
8
+ /// Cost to ENTER (x, z); `None` = blocked. Uniform worlds return Some(1).
9
+ fn cost(&self, x: i32, z: i32) -> Option<u32>;
10
+ }
11
+
12
+ /// A grid of booleans (true = blocked) — the simplest provider, also the
13
+ /// test fixture.
14
+ pub struct GridProvider<'a> {
15
+ pub width: i32,
16
+ pub height: i32,
17
+ pub blocked: &'a dyn Fn(i32, i32) -> bool,
18
+ }
19
+
20
+ impl CostProvider for GridProvider<'_> {
21
+ fn cost(&self, x: i32, z: i32) -> Option<u32> {
22
+ if x < 0 || z < 0 || x >= self.width || z >= self.height || (self.blocked)(x, z) { None } else { Some(1) }
23
+ }
24
+ }
25
+
26
+ pub struct PathConfig {
27
+ /// Max nodes expanded before giving up (the fuel guard). ~4x the
28
+ /// manhattan distance is plenty for sane worlds.
29
+ pub max_expanded: usize,
30
+ }
31
+
32
+ impl Default for PathConfig {
33
+ fn default() -> Self {
34
+ Self { max_expanded: 4_096 }
35
+ }
36
+ }
37
+
38
+ /// 4-connected A*. Returns the path INCLUDING both endpoints, or empty when
39
+ /// unreachable / budget-exhausted / endpoints blocked.
40
+ pub fn a_star(provider: &impl CostProvider, from: (i32, i32), to: (i32, i32), config: &PathConfig) -> Vec<(i32, i32)> {
41
+ use std::collections::{BTreeMap, BinaryHeap};
42
+
43
+ if provider.cost(from.0, from.1).is_none() || provider.cost(to.0, to.1).is_none() {
44
+ return Vec::new();
45
+ }
46
+ if from == to {
47
+ return vec![from];
48
+ }
49
+
50
+ #[derive(PartialEq, Eq)]
51
+ struct Entry {
52
+ f: u32,
53
+ pos: (i32, i32),
54
+ }
55
+ impl Ord for Entry {
56
+ fn cmp(&self, other: &Self) -> std::cmp::Ordering {
57
+ other.f.cmp(&self.f) // min-heap
58
+ }
59
+ }
60
+ impl PartialOrd for Entry {
61
+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
62
+ Some(self.cmp(other))
63
+ }
64
+ }
65
+
66
+ let h = |p: (i32, i32)| p.0.abs_diff(to.0) + p.1.abs_diff(to.1);
67
+ let mut open = BinaryHeap::new();
68
+ let mut g: BTreeMap<(i32, i32), u32> = BTreeMap::new();
69
+ let mut came: BTreeMap<(i32, i32), (i32, i32)> = BTreeMap::new();
70
+ open.push(Entry { f: h(from), pos: from });
71
+ g.insert(from, 0);
72
+
73
+ let mut expanded = 0usize;
74
+ while let Some(Entry { pos, .. }) = open.pop() {
75
+ expanded += 1;
76
+ if expanded > config.max_expanded {
77
+ return Vec::new();
78
+ }
79
+ if pos == to {
80
+ let mut path = vec![to];
81
+ let mut cur = to;
82
+ while let Some(&prev) = came.get(&cur) {
83
+ path.push(prev);
84
+ cur = prev;
85
+ }
86
+ path.reverse();
87
+ return path;
88
+ }
89
+ let g_here = g[&pos];
90
+ for (dx, dz) in [(0, 1), (0, -1), (1, 0), (-1, 0)] {
91
+ let next = (pos.0 + dx, pos.1 + dz);
92
+ let Some(step) = provider.cost(next.0, next.1) else { continue };
93
+ let tentative = g_here.saturating_add(step);
94
+ if tentative < *g.get(&next).unwrap_or(&u32::MAX) {
95
+ g.insert(next, tentative);
96
+ came.insert(next, pos);
97
+ open.push(Entry { f: tentative + h(next), pos: next });
98
+ }
99
+ }
100
+ }
101
+ Vec::new()
102
+ }
103
+
104
+ /// A route: from, then to.
105
+ type Route = ((i32, i32), (i32, i32));
106
+
107
+ /// A generation-keyed path cache: repeated queries between the same cells
108
+ /// reuse the stored path until the world changes (`invalidate()` on any
109
+ /// voxel/tower edit bumps the generation and lazily discards entries).
110
+ #[derive(Default)]
111
+ pub struct PathCache {
112
+ generation: u64,
113
+ entries: std::collections::BTreeMap<Route, (u64, Vec<(i32, i32)>)>,
114
+ }
115
+
116
+ impl PathCache {
117
+ pub fn new() -> Self {
118
+ Self::default()
119
+ }
120
+
121
+ /// Drop every cached path (world changed).
122
+ pub fn invalidate(&mut self) {
123
+ self.generation += 1;
124
+ // Lazy: stale entries are ignored on read and overwritten on write;
125
+ // clear outright when the map has grown past a sane bound.
126
+ if self.entries.len() > 1024 {
127
+ self.entries.clear();
128
+ }
129
+ }
130
+
131
+ /// Cached path or compute-and-store via [`a_star`].
132
+ pub fn get(&mut self, provider: &impl CostProvider, from: (i32, i32), to: (i32, i32), config: &PathConfig) -> Vec<(i32, i32)> {
133
+ if let Some((generation, path)) = self.entries.get(&(from, to))
134
+ && *generation == self.generation
135
+ {
136
+ return path.clone();
137
+ }
138
+ let path = a_star(provider, from, to, config);
139
+ self.entries.insert((from, to), (self.generation, path.clone()));
140
+ path
141
+ }
142
+ }
143
+
144
+ #[cfg(test)]
145
+ mod tests {
146
+ use super::*;
147
+
148
+ fn field<'a>(blocked: &'a dyn Fn(i32, i32) -> bool) -> GridProvider<'a> {
149
+ GridProvider { width: 32, height: 32, blocked }
150
+ }
151
+
152
+ #[test]
153
+ fn straight_line() {
154
+ let open = |_x: i32, _z: i32| false;
155
+ let path = a_star(&field(&open), (0, 0), (5, 0), &PathConfig::default());
156
+ assert_eq!(path.len(), 6);
157
+ assert_eq!(path[0], (0, 0));
158
+ assert_eq!(path[5], (5, 0));
159
+ }
160
+
161
+ #[test]
162
+ fn routes_around_walls() {
163
+ // Vertical wall at x=5 with a gap at z=30.
164
+ let wall = |x: i32, z: i32| x == 5 && z != 30;
165
+ let path = a_star(&field(&wall), (0, 0), (10, 0), &PathConfig::default());
166
+ assert!(!path.is_empty());
167
+ assert!(path.contains(&(5, 30)), "must route through the gap");
168
+ // No step lands on the wall.
169
+ assert!(path.iter().all(|&(x, z)| !(x == 5 && z != 30)));
170
+ // Consecutive steps are 4-connected.
171
+ for pair in path.windows(2) {
172
+ let d = (pair[0].0 - pair[1].0).abs() + (pair[0].1 - pair[1].1).abs();
173
+ assert_eq!(d, 1);
174
+ }
175
+ }
176
+
177
+ #[test]
178
+ fn unreachable_and_budget() {
179
+ let sealed = |x: i32, _z: i32| x == 5; // full wall
180
+ assert!(a_star(&field(&sealed), (0, 0), (10, 0), &PathConfig::default()).is_empty());
181
+ let open = |_x: i32, _z: i32| false;
182
+ let tiny = PathConfig { max_expanded: 3 };
183
+ assert!(a_star(&field(&open), (0, 0), (20, 20), &tiny).is_empty());
184
+ }
185
+
186
+ #[test]
187
+ fn blocked_endpoints_and_identity() {
188
+ let wall = |x: i32, z: i32| x == 0 && z == 0;
189
+ assert!(a_star(&field(&wall), (0, 0), (5, 5), &PathConfig::default()).is_empty());
190
+ let open = |_x: i32, _z: i32| false;
191
+ assert_eq!(a_star(&field(&open), (3, 3), (3, 3), &PathConfig::default()), vec![(3, 3)]);
192
+ }
193
+
194
+ #[test]
195
+ fn cache_reuses_until_invalidated() {
196
+ use std::cell::Cell;
197
+ let calls = Cell::new(0u32);
198
+ let counting = |_x: i32, _z: i32| {
199
+ calls.set(calls.get() + 1);
200
+ false
201
+ };
202
+ let provider = field(&counting);
203
+ let mut cache = PathCache::new();
204
+ let config = PathConfig::default();
205
+ let first = cache.get(&provider, (0, 0), (5, 0), &config);
206
+ let after_first = calls.get();
207
+ let second = cache.get(&provider, (0, 0), (5, 0), &config);
208
+ assert_eq!(first, second);
209
+ assert_eq!(calls.get(), after_first, "cache hit does no provider work");
210
+ cache.invalidate();
211
+ cache.get(&provider, (0, 0), (5, 0), &config);
212
+ assert!(calls.get() > after_first, "invalidation recomputes");
213
+ }
214
+ }
@@ -0,0 +1,137 @@
1
+ //! Steering behaviors: pure functions from positions to a bounded step.
2
+ //! A hub calls one per agent per tick and add the result to the pose.
3
+
4
+ #[derive(Debug, Clone, Copy, PartialEq, Default)]
5
+ pub struct Vec2 {
6
+ pub x: f32,
7
+ pub z: f32,
8
+ }
9
+
10
+ impl Vec2 {
11
+ pub fn new(x: f32, z: f32) -> Self {
12
+ Self { x, z }
13
+ }
14
+
15
+ pub fn len(&self) -> f32 {
16
+ (self.x * self.x + self.z * self.z).sqrt()
17
+ }
18
+
19
+ pub fn to(self, other: Vec2) -> Vec2 {
20
+ Vec2::new(other.x - self.x, other.z - self.z)
21
+ }
22
+
23
+ pub fn dist(&self, other: Vec2) -> f32 {
24
+ self.to(other).len()
25
+ }
26
+
27
+ /// Clamp this displacement to at most `max` length.
28
+ pub fn clamped(self, max: f32) -> Vec2 {
29
+ let len = self.len();
30
+ if len <= max || len == 0.0 { self } else { Vec2::new(self.x / len * max, self.z / len * max) }
31
+ }
32
+
33
+ pub fn yaw(&self) -> f32 {
34
+ self.x.atan2(self.z)
35
+ }
36
+ }
37
+
38
+ /// Step toward `target` at up to `speed*dt`, stopping within `stop_at`.
39
+ pub fn seek(pos: Vec2, target: Vec2, speed: f32, dt: f32, stop_at: f32) -> Vec2 {
40
+ let delta = pos.to(target);
41
+ if delta.len() <= stop_at {
42
+ return Vec2::default();
43
+ }
44
+ delta.clamped(speed * dt)
45
+ }
46
+
47
+ /// Step directly away from `threat`.
48
+ pub fn flee(pos: Vec2, threat: Vec2, speed: f32, dt: f32) -> Vec2 {
49
+ let away = threat.to(pos);
50
+ if away.len() == 0.0 {
51
+ return Vec2::new(speed * dt, 0.0);
52
+ }
53
+ away.clamped(speed * dt)
54
+ }
55
+
56
+ /// Seek that decelerates inside `slow_radius` (smooth stops).
57
+ pub fn arrive(pos: Vec2, target: Vec2, speed: f32, dt: f32, slow_radius: f32) -> Vec2 {
58
+ let delta = pos.to(target);
59
+ let dist = delta.len();
60
+ if dist < 0.05 {
61
+ return Vec2::default();
62
+ }
63
+ let eased = if dist < slow_radius { speed * (dist / slow_radius) } else { speed };
64
+ delta.clamped(eased.max(0.2) * dt)
65
+ }
66
+
67
+ /// Push away from any neighbor closer than `min_dist` (crowd separation).
68
+ pub fn separate(pos: Vec2, neighbors: &[Vec2], min_dist: f32, speed: f32, dt: f32) -> Vec2 {
69
+ let mut push = Vec2::default();
70
+ for n in neighbors {
71
+ let away = n.to(pos);
72
+ let d = away.len();
73
+ if d > 0.0 && d < min_dist {
74
+ let w = (min_dist - d) / min_dist;
75
+ push.x += away.x / d * w;
76
+ push.z += away.z / d * w;
77
+ }
78
+ }
79
+ push.clamped(speed * dt)
80
+ }
81
+
82
+ /// Return-home pull when beyond `leash_radius`; `None` inside the leash.
83
+ pub fn leash(pos: Vec2, home: Vec2, leash_radius: f32, speed: f32, dt: f32) -> Option<Vec2> {
84
+ (pos.dist(home) > leash_radius).then(|| seek(pos, home, speed, dt, 0.5))
85
+ }
86
+
87
+ /// A wander target picker: a point within `range` of `anchor`, clamped to
88
+ /// `bounds` (half-extent box around origin). Pure — feed it your RNG rolls.
89
+ pub fn wander_target(anchor: Vec2, range: f32, roll_x: f32, roll_z: f32, bounds: f32) -> Vec2 {
90
+ Vec2::new(
91
+ (anchor.x + (roll_x - 0.5) * 2.0 * range).clamp(-bounds, bounds),
92
+ (anchor.z + (roll_z - 0.5) * 2.0 * range).clamp(-bounds, bounds),
93
+ )
94
+ }
95
+
96
+ #[cfg(test)]
97
+ mod tests {
98
+ use super::*;
99
+
100
+ #[test]
101
+ fn seek_stops_and_clamps() {
102
+ let step = seek(Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), 2.0, 0.25, 1.0);
103
+ assert!((step.x - 0.5).abs() < 1e-6 && step.z == 0.0);
104
+ assert_eq!(seek(Vec2::new(9.5, 0.0), Vec2::new(10.0, 0.0), 2.0, 0.25, 1.0), Vec2::default());
105
+ }
106
+
107
+ #[test]
108
+ fn flee_moves_away() {
109
+ let step = flee(Vec2::new(1.0, 1.0), Vec2::new(0.0, 0.0), 4.0, 0.25);
110
+ assert!(step.x > 0.0 && step.z > 0.0);
111
+ assert!((step.len() - 1.0).abs() < 1e-5);
112
+ }
113
+
114
+ #[test]
115
+ fn arrive_decelerates() {
116
+ let far = arrive(Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), 4.0, 1.0, 5.0);
117
+ let near = arrive(Vec2::new(96.0, 0.0), Vec2::new(100.0, 0.0), 4.0, 1.0, 5.0);
118
+ assert!(near.len() < far.len());
119
+ }
120
+
121
+ #[test]
122
+ fn separation_and_leash() {
123
+ let push = separate(Vec2::new(0.0, 0.0), &[Vec2::new(0.5, 0.0)], 2.0, 4.0, 0.25);
124
+ assert!(push.x < 0.0); // pushed away from the neighbor
125
+ assert!(leash(Vec2::new(3.0, 0.0), Vec2::default(), 10.0, 2.0, 0.25).is_none());
126
+ let pull = leash(Vec2::new(30.0, 0.0), Vec2::default(), 10.0, 2.0, 0.25).unwrap();
127
+ assert!(pull.x < 0.0);
128
+ }
129
+
130
+ #[test]
131
+ fn wander_stays_in_bounds() {
132
+ for roll in [0.0f32, 0.5, 1.0] {
133
+ let t = wander_target(Vec2::new(39.0, -39.0), 12.0, roll, roll, 40.0);
134
+ assert!(t.x.abs() <= 40.0 && t.z.abs() <= 40.0);
135
+ }
136
+ }
137
+ }
@@ -0,0 +1,141 @@
1
+ //! Budgeted heuristic movers for enemy phases in turn-based games:
2
+ //! enumerate reachable cells under a movement budget, score
3
+ //! each candidate with a game-supplied heuristic, pick the best — all capped
4
+ //! by an expansion budget so fuel stays predictable. Depth-1 "minimax" is
5
+ //! the heuristic itself; deeper search is deliberately out of scope v1.
6
+
7
+ use super::path::CostProvider;
8
+ use std::collections::{BTreeMap, VecDeque};
9
+
10
+ /// One candidate destination for a turn move.
11
+ #[derive(Debug, Clone, Copy, PartialEq)]
12
+ pub struct Candidate {
13
+ pub x: i32,
14
+ pub z: i32,
15
+ /// Movement cost spent to reach it.
16
+ pub cost: u32,
17
+ }
18
+
19
+ /// Enumerate the cells reachable from `from` with at most `move_points`
20
+ /// total entry cost (BFS/Dijkstra-lite over the provider, 4-connected),
21
+ /// including standing still. Expansion stops at `max_expanded` cells.
22
+ pub fn reachable(provider: &impl CostProvider, from: (i32, i32), move_points: u32, max_expanded: usize) -> Vec<Candidate> {
23
+ let mut best_cost: BTreeMap<(i32, i32), u32> = BTreeMap::new();
24
+ let mut queue: VecDeque<((i32, i32), u32)> = VecDeque::new();
25
+ best_cost.insert(from, 0);
26
+ queue.push_back((from, 0));
27
+ let mut expanded = 0usize;
28
+
29
+ while let Some((pos, cost)) = queue.pop_front() {
30
+ expanded += 1;
31
+ if expanded > max_expanded {
32
+ break;
33
+ }
34
+ for (dx, dz) in [(0, 1), (0, -1), (1, 0), (-1, 0)] {
35
+ let next = (pos.0 + dx, pos.1 + dz);
36
+ let Some(step) = provider.cost(next.0, next.1) else { continue };
37
+ let total = cost.saturating_add(step);
38
+ if total > move_points {
39
+ continue;
40
+ }
41
+ if total < *best_cost.get(&next).unwrap_or(&u32::MAX) {
42
+ best_cost.insert(next, total);
43
+ queue.push_back((next, total));
44
+ }
45
+ }
46
+ }
47
+ best_cost.into_iter().map(|((x, z), cost)| Candidate { x, z, cost }).collect()
48
+ }
49
+
50
+ /// The chosen move for a turn.
51
+ #[derive(Debug, Clone, Copy, PartialEq)]
52
+ pub struct TurnMove {
53
+ pub to: (i32, i32),
54
+ pub score: f32,
55
+ }
56
+
57
+ /// Pick the best reachable cell under `move_points` by `score` (higher wins;
58
+ /// ties prefer cheaper movement, then stable coordinate order). Returns
59
+ /// None only when even standing still is impossible (blocked origin).
60
+ pub fn best_move(
61
+ provider: &impl CostProvider,
62
+ from: (i32, i32),
63
+ move_points: u32,
64
+ max_expanded: usize,
65
+ mut score: impl FnMut(&Candidate) -> f32,
66
+ ) -> Option<TurnMove> {
67
+ let candidates = reachable(provider, from, move_points, max_expanded);
68
+ let mut best: Option<(f32, u32, (i32, i32))> = None;
69
+ for candidate in &candidates {
70
+ let s = score(candidate);
71
+ let key = (s, candidate.cost, (candidate.x, candidate.z));
72
+ let better = match &best {
73
+ None => true,
74
+ Some((bs, bc, bp)) => {
75
+ s > *bs || (s == *bs && candidate.cost < *bc) || (s == *bs && candidate.cost == *bc && (candidate.x, candidate.z) < *bp)
76
+ }
77
+ };
78
+ if better {
79
+ best = Some((key.0, key.1, key.2));
80
+ }
81
+ }
82
+ best.map(|(s, _, to)| TurnMove { to, score: s })
83
+ }
84
+
85
+ /// The classic melee-enemy heuristic: get adjacent to the closest target,
86
+ /// preferring cells that minimize distance to ANY target. Returns a score
87
+ /// function for [`best_move`].
88
+ pub fn approach_scorer(targets: Vec<(i32, i32)>) -> impl FnMut(&Candidate) -> f32 {
89
+ move |candidate: &Candidate| {
90
+ let d = targets.iter().map(|t| (t.0 - candidate.x).abs() + (t.1 - candidate.z).abs()).min().unwrap_or(0);
91
+ -(d as f32)
92
+ }
93
+ }
94
+
95
+ #[cfg(test)]
96
+ mod tests {
97
+ use super::*;
98
+ use crate::kit::ai::path::GridProvider;
99
+
100
+ fn open_field<'a>(blocked: &'a dyn Fn(i32, i32) -> bool) -> GridProvider<'a> {
101
+ GridProvider { width: 16, height: 16, blocked }
102
+ }
103
+
104
+ #[test]
105
+ fn reachable_respects_budget_and_walls() {
106
+ let open = |_x: i32, _z: i32| false;
107
+ let cells = reachable(&open_field(&open), (8, 8), 2, 10_000);
108
+ // Manhattan diamond radius 2 = 13 cells (incl. origin).
109
+ assert_eq!(cells.len(), 13);
110
+ assert!(cells.iter().any(|c| c.x == 8 && c.z == 8 && c.cost == 0));
111
+
112
+ let wall = |x: i32, _z: i32| x == 9; // wall right of origin
113
+ let cells = reachable(&open_field(&wall), (8, 8), 2, 10_000);
114
+ assert!(!cells.iter().any(|c| c.x == 9));
115
+ assert!(!cells.iter().any(|c| c.x == 10), "cannot pass through the wall with 2 mp");
116
+ }
117
+
118
+ #[test]
119
+ fn best_move_approaches_target() {
120
+ let open = |_x: i32, _z: i32| false;
121
+ let provider = open_field(&open);
122
+ let chosen = best_move(&provider, (2, 2), 3, 10_000, approach_scorer(vec![(10, 2)])).unwrap();
123
+ assert_eq!(chosen.to, (5, 2), "walks straight toward the target");
124
+ // Already adjacent: stays adjacent (distance 1 beats distance 0? No —
125
+ // approach minimizes distance, landing ON the target cell if open).
126
+ let chosen = best_move(&provider, (9, 2), 1, 10_000, approach_scorer(vec![(10, 2)])).unwrap();
127
+ assert_eq!(chosen.to, (10, 2));
128
+ }
129
+
130
+ #[test]
131
+ fn best_move_prefers_cheaper_on_ties_and_survives_blocked_budget() {
132
+ let open = |_x: i32, _z: i32| false;
133
+ let provider = open_field(&open);
134
+ // Constant score: standing still (cost 0) wins the tie.
135
+ let chosen = best_move(&provider, (4, 4), 3, 10_000, |_| 1.0).unwrap();
136
+ assert_eq!(chosen.to, (4, 4));
137
+ // Zero move points: only standing still is reachable.
138
+ let chosen = best_move(&provider, (4, 4), 0, 10_000, approach_scorer(vec![(9, 9)])).unwrap();
139
+ assert_eq!(chosen.to, (4, 4));
140
+ }
141
+ }
@@ -60,8 +60,12 @@ use serde::{Deserialize, Serialize};
60
60
  use crate::model::{Model, Published, Schema, Viewer};
61
61
  use crate::{Call, Caller, Ctx, Error, Result, decode, encode};
62
62
 
63
+ pub mod ai;
64
+ pub mod play;
65
+ pub mod presence;
63
66
  pub mod rng;
64
67
  pub mod shard;
68
+ pub mod sim;
65
69
  pub mod wire;
66
70
 
67
71
  pub use rng::Rng;
@@ -131,8 +135,19 @@ struct Outgoing<'a, T: ?Sized> {
131
135
  body: &'a T,
132
136
  }
133
137
 
134
- /// Encodes one kit message from this instance. `body` must serialize as a struct or a map.
138
+ /// The fields a kit message's header owns; a body may not use them (they sit side by side).
139
+ pub const RESERVED_FIELDS: [&str; 4] = ["v", "epoch", "seq", "kind"];
140
+
141
+ /// Encodes one kit message from this instance. `body` must serialize as a struct or a map, and
142
+ /// none of its fields may be one of [`RESERVED_FIELDS`]: a debug build (`cargo test`) refuses a
143
+ /// body that uses one, which would leave a client two `kind`s to choose from.
135
144
  pub fn message<T: Serialize + ?Sized>(ctx: &Ctx, kind: MessageKind, seq: u64, body: &T) -> Result<Vec<u8>> {
145
+ #[cfg(debug_assertions)]
146
+ if let Ok(fields) = decode::<BTreeMap<String, serde::de::IgnoredAny>>(&encode(body)?)
147
+ && let Some(taken) = RESERVED_FIELDS.iter().find(|f| fields.contains_key(**f))
148
+ {
149
+ return Err(Error::new(format!("a kit message's body may not have a field `{taken}`: the header owns it")));
150
+ }
136
151
  let header = Header { v: PROTOCOL, epoch: ctx.epoch, seq, kind };
137
152
  encode(&Outgoing { header, body })
138
153
  }
@@ -237,6 +252,15 @@ pub fn args<T: DeserializeOwned>(call: &Call<'_>) -> Result<T> {
237
252
  decode(call.payload).map_err(|Error(e)| error(codes::BAD_REQUEST, format!("`{}`: {e}", call.method)))
238
253
  }
239
254
 
255
+ /// A call's arguments when it may send none: an empty payload or a nil (what a client sends for
256
+ /// no arguments) is `T::default()`.
257
+ pub fn args_or_default<T: DeserializeOwned + Default>(call: &Call<'_>) -> Result<T> {
258
+ match call.payload {
259
+ [] | [0xc0] => Ok(T::default()),
260
+ _ => args(call),
261
+ }
262
+ }
263
+
240
264
  // ---- refusals ----
241
265
 
242
266
  /// The refusal codes kit hubs share. A hub may add its own: lowercase letters, digits and `_`.
@@ -417,6 +441,13 @@ mod tests {
417
441
  assert!(message(&ctx(), MessageKind::Event, 1, &vec![1u8]).is_err(), "a body is a struct or a map");
418
442
  }
419
443
 
444
+ #[test]
445
+ fn a_body_may_not_use_a_field_the_header_owns() {
446
+ let e = message(&ctx(), MessageKind::Event, 1, &serde_json::json!({ "type": "timing", "kind": "lap" })).unwrap_err();
447
+ assert!(e.0.contains("`kind`"), "{}", e.0);
448
+ assert!(message(&ctx(), MessageKind::Event, 1, &serde_json::json!({ "type": "timing", "stage": "lap" })).is_ok());
449
+ }
450
+
420
451
  #[test]
421
452
  fn each_topic_counts_its_own_messages_and_a_snapshot_reply_names_the_last() {
422
453
  let mut p = Publisher::new();
@@ -480,6 +511,23 @@ mod tests {
480
511
  assert!(Receipts::action_id(&encode(&Act { points: 1, action_id: &long }).unwrap()).is_err());
481
512
  }
482
513
 
514
+ #[test]
515
+ fn no_arguments_are_the_defaults_and_bad_ones_are_a_bad_request() {
516
+ #[derive(Deserialize, Default, Debug, PartialEq)]
517
+ #[serde(default)]
518
+ struct Open {
519
+ seed: Option<u64>,
520
+ }
521
+ let call = |payload: &'static [u8]| Call { caller: Caller::Player(1), method: "open", payload };
522
+ assert_eq!(args_or_default::<Open>(&call(&[])).unwrap(), Open::default());
523
+ assert_eq!(args_or_default::<Open>(&call(&[0xc0])).unwrap(), Open::default());
524
+ let given = encode(&serde_json::json!({ "seed": 4 })).unwrap();
525
+ let c = Call { caller: Caller::Player(1), method: "open", payload: &given };
526
+ assert_eq!(args_or_default::<Open>(&c).unwrap(), Open { seed: Some(4) });
527
+ let Error(e) = args_or_default::<Open>(&call(&[0x05])).unwrap_err();
528
+ assert_eq!(error_code(&e).map(|c| c.0), Some(codes::BAD_REQUEST));
529
+ }
530
+
483
531
  #[test]
484
532
  fn a_collection_snapshot_holds_every_public_view() {
485
533
  let schema = Schema::new()