@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.
- package/README.md +5 -2
- package/crates/ckx-sdk/Cargo.toml +1 -1
- package/crates/ckx-sdk/src/kit/ai/brain.rs +288 -0
- package/crates/ckx-sdk/src/kit/ai/flow.rs +163 -0
- package/crates/ckx-sdk/src/kit/ai/mod.rs +10 -0
- package/crates/ckx-sdk/src/kit/ai/path.rs +214 -0
- package/crates/ckx-sdk/src/kit/ai/steering.rs +137 -0
- package/crates/ckx-sdk/src/kit/ai/turn.rs +141 -0
- package/crates/ckx-sdk/src/kit/mod.rs +49 -1
- package/crates/ckx-sdk/src/kit/play/abilities.rs +370 -0
- package/crates/ckx-sdk/src/kit/play/cards.rs +220 -0
- package/crates/ckx-sdk/src/kit/play/combat.rs +292 -0
- package/crates/ckx-sdk/src/kit/play/mod.rs +15 -0
- package/crates/ckx-sdk/src/kit/play/movement.rs +196 -0
- package/crates/ckx-sdk/src/kit/play/score.rs +183 -0
- package/crates/ckx-sdk/src/kit/play/timing.rs +218 -0
- package/crates/ckx-sdk/src/kit/play/turns.rs +315 -0
- package/crates/ckx-sdk/src/kit/presence.rs +142 -0
- package/crates/ckx-sdk/src/kit/rng.rs +10 -0
- package/crates/ckx-sdk/src/kit/sim/daycycle.rs +71 -0
- package/crates/ckx-sdk/src/kit/sim/mod.rs +10 -0
- package/crates/ckx-sdk/src/kit/sim/nodes.rs +109 -0
- package/crates/ckx-sdk/src/kit/sim/waves.rs +105 -0
- package/crates/ckx-sdk/src/kit/sim/weather.rs +142 -0
- package/crates/ckx-sdk/src/kit/sim/zones.rs +218 -0
- package/dist/cli/src/new.js +9 -0
- package/kit.pin +4 -4
- package/package.json +1 -1
- package/starters/arena/Cargo.toml +14 -0
- package/starters/arena/src/lib.rs +849 -0
- package/starters/ckx.json +12 -1
- package/starters/deck/Cargo.toml +14 -0
- package/starters/deck/src/lib.rs +510 -0
- package/starters/director/Cargo.toml +14 -0
- package/starters/director/src/lib.rs +628 -0
- package/starters/grid-mod/Cargo.toml +1 -1
- package/starters/instance/Cargo.toml +14 -0
- package/starters/instance/src/lib.rs +656 -0
- package/starters/leaderboard/Cargo.toml +14 -0
- package/starters/leaderboard/src/lib.rs +395 -0
- package/starters/matchmaker/Cargo.toml +1 -1
- package/starters/minigame/Cargo.toml +14 -0
- package/starters/minigame/src/lib.rs +391 -0
- package/starters/npc-mobs/Cargo.toml +1 -1
- package/starters/racing/Cargo.toml +14 -0
- package/starters/racing/src/lib.rs +1151 -0
- package/starters/session/Cargo.toml +1 -1
- package/starters/tactics/Cargo.toml +14 -0
- package/starters/tactics/src/lib.rs +562 -0
- package/starters/territory/Cargo.toml +14 -0
- package/starters/territory/src/lib.rs +878 -0
- 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
|
-
///
|
|
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()
|