@crowdedkingdoms/ckx-kit 0.2.0 → 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 +13 -1
- 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 +542 -0
- 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 +137 -0
- package/crates/ckx-sdk/src/kit/shard.rs +65 -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/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/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/matchmaker/src/lib.rs +334 -74
- 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/npc-mobs/src/lib.rs +277 -67
- package/starters/racing/Cargo.toml +14 -0
- package/starters/racing/src/lib.rs +1151 -0
- package/starters/session/Cargo.toml +1 -1
- package/starters/session/src/lib.rs +191 -86
- 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
- package/starters/world-tick/src/lib.rs +151 -38
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
//! Race timing: ordered checkpoint/gate evaluation from pose streams,
|
|
2
|
+
//! lap/split timing, best-lap books, and compact ghost recording (pose
|
|
3
|
+
//! track capture + interpolated replay). Pure state machines fed by the
|
|
4
|
+
//! race hub's tick samples — gates are proximity circles, so tick-rate
|
|
5
|
+
//! sampling with generous radii is the tolerance model (documented for
|
|
6
|
+
//! course designers).
|
|
7
|
+
|
|
8
|
+
use serde::{Deserialize, Serialize};
|
|
9
|
+
|
|
10
|
+
/// One ordered gate on a course.
|
|
11
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
|
|
12
|
+
pub struct Gate {
|
|
13
|
+
pub x: f32,
|
|
14
|
+
pub z: f32,
|
|
15
|
+
pub radius: f32,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// A course: ordered gates; a lap = passing every gate in order (gate 0 is
|
|
19
|
+
/// the start/finish line).
|
|
20
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
21
|
+
pub struct Course {
|
|
22
|
+
pub gates: Vec<Gate>,
|
|
23
|
+
pub laps: u32,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Events produced by feeding poses through a run timer.
|
|
27
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
28
|
+
pub enum TimingEvent {
|
|
29
|
+
Started { at_ms: u64 },
|
|
30
|
+
Checkpoint { gate: usize, split_ms: u64 },
|
|
31
|
+
LapCompleted { lap: u32, lap_ms: u64, best: bool },
|
|
32
|
+
Finished { total_ms: u64, best_lap_ms: u64 },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// One racer's live run state.
|
|
36
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
37
|
+
pub struct RunTimer {
|
|
38
|
+
pub next_gate: usize,
|
|
39
|
+
pub lap: u32,
|
|
40
|
+
pub started_at_ms: u64,
|
|
41
|
+
pub lap_started_at_ms: u64,
|
|
42
|
+
pub splits_ms: Vec<u64>,
|
|
43
|
+
pub best_lap_ms: u64,
|
|
44
|
+
pub finished: bool,
|
|
45
|
+
pub total_ms: u64,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
impl RunTimer {
|
|
49
|
+
/// Feed one pose sample; returns the timing events it produced. The run
|
|
50
|
+
/// starts when the racer first touches gate 0.
|
|
51
|
+
pub fn feed(&mut self, course: &Course, pos: (f32, f32), now_ms: u64) -> Vec<TimingEvent> {
|
|
52
|
+
if self.finished || course.gates.is_empty() {
|
|
53
|
+
return Vec::new();
|
|
54
|
+
}
|
|
55
|
+
let mut events = Vec::new();
|
|
56
|
+
let gate = course.gates[self.next_gate];
|
|
57
|
+
let (dx, dz) = (pos.0 - gate.x, pos.1 - gate.z);
|
|
58
|
+
if (dx * dx + dz * dz).sqrt() > gate.radius {
|
|
59
|
+
return events;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if self.started_at_ms == 0 {
|
|
63
|
+
// Touching the start line arms the run.
|
|
64
|
+
if self.next_gate == 0 {
|
|
65
|
+
self.started_at_ms = now_ms;
|
|
66
|
+
self.lap_started_at_ms = now_ms;
|
|
67
|
+
self.lap = 1;
|
|
68
|
+
self.next_gate = 1 % course.gates.len();
|
|
69
|
+
events.push(TimingEvent::Started { at_ms: now_ms });
|
|
70
|
+
}
|
|
71
|
+
return events;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let split = now_ms.saturating_sub(self.lap_started_at_ms);
|
|
75
|
+
self.splits_ms.push(split);
|
|
76
|
+
events.push(TimingEvent::Checkpoint { gate: self.next_gate, split_ms: split });
|
|
77
|
+
self.next_gate = (self.next_gate + 1) % course.gates.len();
|
|
78
|
+
|
|
79
|
+
// Wrapped back to the start line: lap complete.
|
|
80
|
+
if self.next_gate == 1 % course.gates.len() && self.splits_ms.len().is_multiple_of(course.gates.len()) {
|
|
81
|
+
let lap_ms = now_ms.saturating_sub(self.lap_started_at_ms);
|
|
82
|
+
let best = self.best_lap_ms == 0 || lap_ms < self.best_lap_ms;
|
|
83
|
+
if best {
|
|
84
|
+
self.best_lap_ms = lap_ms;
|
|
85
|
+
}
|
|
86
|
+
events.push(TimingEvent::LapCompleted { lap: self.lap, lap_ms, best });
|
|
87
|
+
if self.lap >= course.laps.max(1) {
|
|
88
|
+
self.finished = true;
|
|
89
|
+
self.total_ms = now_ms.saturating_sub(self.started_at_ms);
|
|
90
|
+
events.push(TimingEvent::Finished { total_ms: self.total_ms, best_lap_ms: self.best_lap_ms });
|
|
91
|
+
} else {
|
|
92
|
+
self.lap += 1;
|
|
93
|
+
self.lap_started_at_ms = now_ms;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
events
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Ghosts
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/// A compact recorded pose track: (offset ms, x, y, z, yaw). Sampled at the
|
|
105
|
+
/// recorder's cadence, replayed with linear interpolation.
|
|
106
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
107
|
+
pub struct GhostTrack {
|
|
108
|
+
pub frames: Vec<(u32, f32, f32, f32, f32)>,
|
|
109
|
+
pub started_at_ms: u64,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Ghost tracks are capped so a long run cannot blow the state budget.
|
|
113
|
+
pub const GHOST_MAX_FRAMES: usize = 600;
|
|
114
|
+
|
|
115
|
+
impl GhostTrack {
|
|
116
|
+
/// Record one frame (call per sample while the run is live).
|
|
117
|
+
pub fn record(&mut self, now_ms: u64, x: f32, y: f32, z: f32, yaw: f32) {
|
|
118
|
+
if self.started_at_ms == 0 {
|
|
119
|
+
self.started_at_ms = now_ms;
|
|
120
|
+
}
|
|
121
|
+
if self.frames.len() >= GHOST_MAX_FRAMES {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
let offset = now_ms.saturating_sub(self.started_at_ms) as u32;
|
|
125
|
+
self.frames.push((offset, x, y, z, yaw));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
pub fn duration_ms(&self) -> u32 {
|
|
129
|
+
self.frames.last().map(|f| f.0).unwrap_or(0)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Interpolated pose at `offset_ms` into the replay (clamped to ends).
|
|
133
|
+
pub fn frame_at(&self, offset_ms: u32) -> Option<(f32, f32, f32, f32)> {
|
|
134
|
+
if self.frames.is_empty() {
|
|
135
|
+
return None;
|
|
136
|
+
}
|
|
137
|
+
let last = *self.frames.last().unwrap();
|
|
138
|
+
if offset_ms >= last.0 {
|
|
139
|
+
return Some((last.1, last.2, last.3, last.4));
|
|
140
|
+
}
|
|
141
|
+
let after_index = self.frames.iter().position(|f| f.0 >= offset_ms)?;
|
|
142
|
+
if after_index == 0 {
|
|
143
|
+
let f = self.frames[0];
|
|
144
|
+
return Some((f.1, f.2, f.3, f.4));
|
|
145
|
+
}
|
|
146
|
+
let before = self.frames[after_index - 1];
|
|
147
|
+
let after = self.frames[after_index];
|
|
148
|
+
let span = (after.0 - before.0).max(1) as f32;
|
|
149
|
+
let t = (offset_ms - before.0) as f32 / span;
|
|
150
|
+
Some((
|
|
151
|
+
before.1 + (after.1 - before.1) * t,
|
|
152
|
+
before.2 + (after.2 - before.2) * t,
|
|
153
|
+
before.3 + (after.3 - before.3) * t,
|
|
154
|
+
before.4 + (after.4 - before.4) * t,
|
|
155
|
+
))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#[cfg(test)]
|
|
160
|
+
mod tests {
|
|
161
|
+
use super::*;
|
|
162
|
+
|
|
163
|
+
fn triangle() -> Course {
|
|
164
|
+
Course {
|
|
165
|
+
gates: vec![Gate { x: 0.0, z: 0.0, radius: 2.0 }, Gate { x: 10.0, z: 0.0, radius: 2.0 }, Gate { x: 5.0, z: 8.0, radius: 2.0 }],
|
|
166
|
+
laps: 2,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#[test]
|
|
171
|
+
fn ordered_gates_laps_and_finish() {
|
|
172
|
+
let course = triangle();
|
|
173
|
+
let mut timer = RunTimer::default();
|
|
174
|
+
// Touching gate 1 before starting does nothing.
|
|
175
|
+
assert!(timer.feed(&course, (10.0, 0.0), 500).is_empty());
|
|
176
|
+
// Start at gate 0.
|
|
177
|
+
let events = timer.feed(&course, (0.5, 0.0), 1_000);
|
|
178
|
+
assert_eq!(events, vec![TimingEvent::Started { at_ms: 1_000 }]);
|
|
179
|
+
// Skipping a gate is ignored (gate 2 while gate 1 is next).
|
|
180
|
+
assert!(timer.feed(&course, (5.0, 8.0), 2_000).is_empty());
|
|
181
|
+
// Gate 1 -> checkpoint split.
|
|
182
|
+
let events = timer.feed(&course, (10.0, 0.5), 3_000);
|
|
183
|
+
assert_eq!(events, vec![TimingEvent::Checkpoint { gate: 1, split_ms: 2_000 }]);
|
|
184
|
+
timer.feed(&course, (5.0, 8.0), 5_000); // gate 2
|
|
185
|
+
// Back to start: lap 1 done (best).
|
|
186
|
+
let events = timer.feed(&course, (0.0, 0.5), 7_000);
|
|
187
|
+
assert!(events.contains(&TimingEvent::LapCompleted { lap: 1, lap_ms: 6_000, best: true }));
|
|
188
|
+
assert_eq!(timer.lap, 2);
|
|
189
|
+
// Lap 2, faster: finish with best lap = lap 2.
|
|
190
|
+
timer.feed(&course, (10.0, 0.0), 8_000);
|
|
191
|
+
timer.feed(&course, (5.0, 8.0), 9_000);
|
|
192
|
+
let events = timer.feed(&course, (0.0, 0.0), 11_000);
|
|
193
|
+
assert!(events.contains(&TimingEvent::LapCompleted { lap: 2, lap_ms: 4_000, best: true }));
|
|
194
|
+
assert!(events.contains(&TimingEvent::Finished { total_ms: 10_000, best_lap_ms: 4_000 }));
|
|
195
|
+
assert!(timer.finished);
|
|
196
|
+
assert!(timer.feed(&course, (10.0, 0.0), 12_000).is_empty(), "finished runs ignore input");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#[test]
|
|
200
|
+
fn ghost_records_and_interpolates() {
|
|
201
|
+
let mut ghost = GhostTrack::default();
|
|
202
|
+
ghost.record(1_000, 0.0, 0.0, 0.0, 0.0);
|
|
203
|
+
ghost.record(2_000, 10.0, 0.0, 0.0, 90.0);
|
|
204
|
+
ghost.record(3_000, 10.0, 0.0, 10.0, 180.0);
|
|
205
|
+
assert_eq!(ghost.duration_ms(), 2_000);
|
|
206
|
+
let mid = ghost.frame_at(500).unwrap();
|
|
207
|
+
assert!((mid.0 - 5.0).abs() < 1e-3);
|
|
208
|
+
assert!((mid.3 - 45.0).abs() < 1e-3);
|
|
209
|
+
let end = ghost.frame_at(9_999).unwrap();
|
|
210
|
+
assert_eq!(end.2, 10.0);
|
|
211
|
+
// Frame cap holds.
|
|
212
|
+
let mut big = GhostTrack::default();
|
|
213
|
+
for i in 0..(GHOST_MAX_FRAMES + 50) {
|
|
214
|
+
big.record(i as u64 * 100, i as f32, 0.0, 0.0, 0.0);
|
|
215
|
+
}
|
|
216
|
+
assert_eq!(big.frames.len(), GHOST_MAX_FRAMES);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
//! Turns: initiative ordering, turn/round advance, per-turn timers
|
|
2
|
+
//! with timeout policies, and simultaneous reveal. Pure state machine over
|
|
3
|
+
//! `now_ms` — a hub keeps it in its state and drives it from
|
|
4
|
+
//! timers and calls; it never talks to the platform itself.
|
|
5
|
+
|
|
6
|
+
use serde::{Deserialize, Serialize};
|
|
7
|
+
|
|
8
|
+
/// What happens when a turn timer expires.
|
|
9
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Default)]
|
|
10
|
+
pub enum TimeoutPolicy {
|
|
11
|
+
/// The actor's turn is skipped (turn passes on).
|
|
12
|
+
#[default]
|
|
13
|
+
Skip,
|
|
14
|
+
/// The actor forfeits and is removed from the order.
|
|
15
|
+
Forfeit,
|
|
16
|
+
/// Nothing happens automatically (a human host decides).
|
|
17
|
+
Wait,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// One participant in the order.
|
|
21
|
+
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
22
|
+
pub struct TurnActor {
|
|
23
|
+
/// Stable id (a player id, a unit's id, ...).
|
|
24
|
+
pub id: String,
|
|
25
|
+
/// Higher acts first; ties keep insertion order (stable sort).
|
|
26
|
+
pub initiative: i64,
|
|
27
|
+
/// Forfeited/dead actors stay recorded but are skipped.
|
|
28
|
+
pub active: bool,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// An event the hub should announce (turn changed, round advanced, ...).
|
|
32
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
33
|
+
pub enum TurnEvent {
|
|
34
|
+
TurnStarted {
|
|
35
|
+
actor_id: String,
|
|
36
|
+
round: u32,
|
|
37
|
+
turn_in_round: u32,
|
|
38
|
+
},
|
|
39
|
+
RoundStarted {
|
|
40
|
+
round: u32,
|
|
41
|
+
},
|
|
42
|
+
ActorSkipped {
|
|
43
|
+
actor_id: String,
|
|
44
|
+
},
|
|
45
|
+
ActorForfeited {
|
|
46
|
+
actor_id: String,
|
|
47
|
+
},
|
|
48
|
+
/// One (or zero) active actors remain.
|
|
49
|
+
OrderExhausted,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
53
|
+
pub struct TurnOrder {
|
|
54
|
+
pub actors: Vec<TurnActor>,
|
|
55
|
+
/// Index into `actors` of the current turn holder.
|
|
56
|
+
pub current: usize,
|
|
57
|
+
pub round: u32,
|
|
58
|
+
pub turn_in_round: u32,
|
|
59
|
+
/// Current turn deadline (0 = no timer).
|
|
60
|
+
pub deadline_ms: u64,
|
|
61
|
+
pub turn_duration_ms: u64,
|
|
62
|
+
pub timeout_policy: TimeoutPolicy,
|
|
63
|
+
started: bool,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
impl TurnOrder {
|
|
67
|
+
/// Build an order from (id, initiative) pairs; sorts by initiative
|
|
68
|
+
/// descending (stable — ties keep the given order).
|
|
69
|
+
pub fn new(actors: impl IntoIterator<Item = (String, i64)>, turn_duration_ms: u64, timeout_policy: TimeoutPolicy) -> Self {
|
|
70
|
+
let mut list: Vec<TurnActor> = actors.into_iter().map(|(id, initiative)| TurnActor { id, initiative, active: true }).collect();
|
|
71
|
+
list.sort_by_key(|b| std::cmp::Reverse(b.initiative));
|
|
72
|
+
Self { actors: list, current: 0, round: 0, turn_in_round: 0, deadline_ms: 0, turn_duration_ms, timeout_policy, started: false }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
pub fn active_count(&self) -> usize {
|
|
76
|
+
self.actors.iter().filter(|a| a.active).count()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// The current turn holder (None before start / after exhaustion).
|
|
80
|
+
pub fn current_actor(&self) -> Option<&TurnActor> {
|
|
81
|
+
if !self.started {
|
|
82
|
+
return None;
|
|
83
|
+
}
|
|
84
|
+
self.actors.get(self.current).filter(|a| a.active)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Begin round 1, turn 1. Returns the announce events.
|
|
88
|
+
pub fn start(&mut self, now_ms: u64) -> Vec<TurnEvent> {
|
|
89
|
+
if self.started || self.active_count() == 0 {
|
|
90
|
+
return Vec::new();
|
|
91
|
+
}
|
|
92
|
+
self.started = true;
|
|
93
|
+
self.round = 1;
|
|
94
|
+
self.turn_in_round = 1;
|
|
95
|
+
self.current = self.first_active_from(0);
|
|
96
|
+
self.arm_timer(now_ms);
|
|
97
|
+
vec![
|
|
98
|
+
TurnEvent::RoundStarted { round: 1 },
|
|
99
|
+
TurnEvent::TurnStarted { actor_id: self.actors[self.current].id.clone(), round: 1, turn_in_round: 1 },
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// Advance to the next active actor (call when the current turn ends).
|
|
104
|
+
pub fn advance(&mut self, now_ms: u64) -> Vec<TurnEvent> {
|
|
105
|
+
if !self.started {
|
|
106
|
+
return Vec::new();
|
|
107
|
+
}
|
|
108
|
+
let mut events = Vec::new();
|
|
109
|
+
if self.active_count() <= 1 {
|
|
110
|
+
events.push(TurnEvent::OrderExhausted);
|
|
111
|
+
return events;
|
|
112
|
+
}
|
|
113
|
+
let mut index = self.current;
|
|
114
|
+
loop {
|
|
115
|
+
index += 1;
|
|
116
|
+
if index >= self.actors.len() {
|
|
117
|
+
index = 0;
|
|
118
|
+
self.round += 1;
|
|
119
|
+
self.turn_in_round = 0;
|
|
120
|
+
events.push(TurnEvent::RoundStarted { round: self.round });
|
|
121
|
+
}
|
|
122
|
+
if self.actors[index].active {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
self.current = index;
|
|
127
|
+
self.turn_in_round += 1;
|
|
128
|
+
self.arm_timer(now_ms);
|
|
129
|
+
events.push(TurnEvent::TurnStarted {
|
|
130
|
+
actor_id: self.actors[index].id.clone(),
|
|
131
|
+
round: self.round,
|
|
132
|
+
turn_in_round: self.turn_in_round,
|
|
133
|
+
});
|
|
134
|
+
events
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Apply the timeout policy if the current turn's deadline passed.
|
|
138
|
+
pub fn tick(&mut self, now_ms: u64) -> Vec<TurnEvent> {
|
|
139
|
+
if !self.started || self.deadline_ms == 0 || now_ms < self.deadline_ms {
|
|
140
|
+
return Vec::new();
|
|
141
|
+
}
|
|
142
|
+
let actor_id = match self.current_actor() {
|
|
143
|
+
Some(actor) => actor.id.clone(),
|
|
144
|
+
None => return Vec::new(),
|
|
145
|
+
};
|
|
146
|
+
match self.timeout_policy {
|
|
147
|
+
TimeoutPolicy::Wait => Vec::new(),
|
|
148
|
+
TimeoutPolicy::Skip => {
|
|
149
|
+
let mut events = vec![TurnEvent::ActorSkipped { actor_id }];
|
|
150
|
+
events.extend(self.advance(now_ms));
|
|
151
|
+
events
|
|
152
|
+
}
|
|
153
|
+
TimeoutPolicy::Forfeit => {
|
|
154
|
+
let mut events = vec![TurnEvent::ActorForfeited { actor_id: actor_id.clone() }];
|
|
155
|
+
events.extend(self.forfeit(&actor_id, now_ms));
|
|
156
|
+
events
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// Remove an actor from the running order (death, rage-quit). Advances
|
|
162
|
+
/// the turn when it was theirs.
|
|
163
|
+
pub fn forfeit(&mut self, actor_id: &str, now_ms: u64) -> Vec<TurnEvent> {
|
|
164
|
+
let Some(index) = self.actors.iter().position(|a| a.id == actor_id && a.active) else {
|
|
165
|
+
return Vec::new();
|
|
166
|
+
};
|
|
167
|
+
self.actors[index].active = false;
|
|
168
|
+
if self.active_count() <= 1 {
|
|
169
|
+
return vec![TurnEvent::OrderExhausted];
|
|
170
|
+
}
|
|
171
|
+
if index == self.current && self.started {
|
|
172
|
+
// Their turn: pass it on. advance() skips inactive actors.
|
|
173
|
+
return self.advance(now_ms);
|
|
174
|
+
}
|
|
175
|
+
Vec::new()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// Milliseconds left on the current turn (None = no timer / not started).
|
|
179
|
+
pub fn remaining_ms(&self, now_ms: u64) -> Option<u64> {
|
|
180
|
+
(self.started && self.deadline_ms > 0).then(|| self.deadline_ms.saturating_sub(now_ms))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fn arm_timer(&mut self, now_ms: u64) {
|
|
184
|
+
self.deadline_ms = if self.turn_duration_ms > 0 { now_ms + self.turn_duration_ms } else { 0 };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
fn first_active_from(&self, start: usize) -> usize {
|
|
188
|
+
(start..self.actors.len()).find(|&i| self.actors[i].active).unwrap_or(0)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Simultaneous reveal: everyone commits a move, then all resolve at once.
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/// Collects one hidden submission per actor, then resolves when everyone
|
|
197
|
+
/// (or the deadline) is in. Submissions stay in the hub until the reveal.
|
|
198
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
199
|
+
pub struct RevealRound {
|
|
200
|
+
pub expected: Vec<String>,
|
|
201
|
+
pub submissions: Vec<(String, serde_json::Value)>,
|
|
202
|
+
pub deadline_ms: u64,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
impl RevealRound {
|
|
206
|
+
pub fn new(expected: Vec<String>, now_ms: u64, window_ms: u64) -> Self {
|
|
207
|
+
Self { expected, submissions: Vec::new(), deadline_ms: if window_ms > 0 { now_ms + window_ms } else { 0 } }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/// Record a submission. Duplicates and strangers are rejected.
|
|
211
|
+
pub fn submit(&mut self, actor_id: &str, payload: serde_json::Value) -> Result<(), &'static str> {
|
|
212
|
+
if !self.expected.iter().any(|id| id == actor_id) {
|
|
213
|
+
return Err("not a participant");
|
|
214
|
+
}
|
|
215
|
+
if self.submissions.iter().any(|(id, _)| id == actor_id) {
|
|
216
|
+
return Err("already submitted");
|
|
217
|
+
}
|
|
218
|
+
self.submissions.push((actor_id.to_string(), payload));
|
|
219
|
+
Ok(())
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
pub fn complete(&self) -> bool {
|
|
223
|
+
self.submissions.len() == self.expected.len()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// Ready to reveal: everyone submitted, or the window closed.
|
|
227
|
+
pub fn ready(&self, now_ms: u64) -> bool {
|
|
228
|
+
self.complete() || (self.deadline_ms > 0 && now_ms >= self.deadline_ms)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// Consume and return all submissions (the reveal).
|
|
232
|
+
pub fn reveal(self) -> Vec<(String, serde_json::Value)> {
|
|
233
|
+
self.submissions
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#[cfg(test)]
|
|
238
|
+
mod tests {
|
|
239
|
+
use super::*;
|
|
240
|
+
use serde_json::json;
|
|
241
|
+
|
|
242
|
+
fn order3() -> TurnOrder {
|
|
243
|
+
TurnOrder::new([("slow".into(), 3), ("fast".into(), 9), ("mid".into(), 5)], 10_000, TimeoutPolicy::Skip)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#[test]
|
|
247
|
+
fn initiative_orders_and_rounds_advance() {
|
|
248
|
+
let mut order = order3();
|
|
249
|
+
let events = order.start(1_000);
|
|
250
|
+
assert!(matches!(&events[1], TurnEvent::TurnStarted { actor_id, round: 1, .. } if actor_id == "fast"));
|
|
251
|
+
assert_eq!(order.current_actor().unwrap().id, "fast");
|
|
252
|
+
|
|
253
|
+
order.advance(2_000);
|
|
254
|
+
assert_eq!(order.current_actor().unwrap().id, "mid");
|
|
255
|
+
order.advance(3_000);
|
|
256
|
+
assert_eq!(order.current_actor().unwrap().id, "slow");
|
|
257
|
+
// Wrap: new round.
|
|
258
|
+
let events = order.advance(4_000);
|
|
259
|
+
assert!(events.iter().any(|e| matches!(e, TurnEvent::RoundStarted { round: 2 })));
|
|
260
|
+
assert_eq!(order.current_actor().unwrap().id, "fast");
|
|
261
|
+
assert_eq!(order.round, 2);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#[test]
|
|
265
|
+
fn timeout_skips_and_forfeit_policy_removes() {
|
|
266
|
+
let mut order = order3();
|
|
267
|
+
order.start(0);
|
|
268
|
+
assert!(order.tick(9_999).is_empty(), "before the deadline");
|
|
269
|
+
let events = order.tick(10_000);
|
|
270
|
+
assert!(matches!(&events[0], TurnEvent::ActorSkipped { actor_id } if actor_id == "fast"));
|
|
271
|
+
assert_eq!(order.current_actor().unwrap().id, "mid");
|
|
272
|
+
|
|
273
|
+
let mut hard = TurnOrder::new([("a".into(), 2), ("b".into(), 1)], 5_000, TimeoutPolicy::Forfeit);
|
|
274
|
+
hard.start(0);
|
|
275
|
+
let events = hard.tick(5_000);
|
|
276
|
+
assert!(matches!(&events[0], TurnEvent::ActorForfeited { actor_id } if actor_id == "a"));
|
|
277
|
+
assert!(events.iter().any(|e| matches!(e, TurnEvent::OrderExhausted)));
|
|
278
|
+
assert_eq!(hard.active_count(), 1);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#[test]
|
|
282
|
+
fn forfeit_mid_order_passes_turn() {
|
|
283
|
+
let mut order = order3();
|
|
284
|
+
order.start(0);
|
|
285
|
+
// "mid" (not current) forfeits: current turn unaffected.
|
|
286
|
+
assert!(order.forfeit("mid", 1_000).is_empty());
|
|
287
|
+
assert_eq!(order.current_actor().unwrap().id, "fast");
|
|
288
|
+
// Current forfeits: turn passes to the next active ("slow").
|
|
289
|
+
let events = order.forfeit("fast", 2_000);
|
|
290
|
+
assert!(events.iter().any(|e| matches!(e, TurnEvent::OrderExhausted)));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#[test]
|
|
294
|
+
fn reveal_round_collects_then_reveals() {
|
|
295
|
+
let mut round = RevealRound::new(vec!["a".into(), "b".into()], 0, 30_000);
|
|
296
|
+
assert_eq!(round.submit("x", json!(1)).unwrap_err(), "not a participant");
|
|
297
|
+
round.submit("a", json!("rock")).unwrap();
|
|
298
|
+
assert_eq!(round.submit("a", json!("paper")).unwrap_err(), "already submitted");
|
|
299
|
+
assert!(!round.ready(1_000), "waiting on b");
|
|
300
|
+
assert!(round.ready(30_000), "window closed");
|
|
301
|
+
round.submit("b", json!("scissors")).unwrap();
|
|
302
|
+
assert!(round.complete() && round.ready(1_000));
|
|
303
|
+
let submissions = round.reveal();
|
|
304
|
+
assert_eq!(submissions.len(), 2);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#[test]
|
|
308
|
+
fn serde_roundtrip() {
|
|
309
|
+
let mut order = order3();
|
|
310
|
+
order.start(0);
|
|
311
|
+
let json = serde_json::to_string(&order).unwrap();
|
|
312
|
+
let restored: TurnOrder = serde_json::from_str(&json).unwrap();
|
|
313
|
+
assert_eq!(restored.current_actor().unwrap().id, "fast");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
//! Where players are, for hubs that referee space (an arena, a race, a ball, a capture point, a
|
|
2
|
+
//! shrinking zone): the actors near a place, read with the node API (`world.read`), each
|
|
3
|
+
//! player's position as precise as their actor's state allows.
|
|
4
|
+
//!
|
|
5
|
+
//! Positions are in chunks, fractional. An actor whose state is an [`ActorState`] map or the
|
|
6
|
+
//! 48-byte engine pose (`f32` x, y, z in blocks first, 16 blocks a chunk) is placed exactly;
|
|
7
|
+
//! any other is placed at its chunk's centre, so a game that wants the hub to see more than
|
|
8
|
+
//! which chunk a player is in replicates its players with one of those two.
|
|
9
|
+
|
|
10
|
+
use std::collections::BTreeMap;
|
|
11
|
+
|
|
12
|
+
use serde::{Deserialize, Serialize};
|
|
13
|
+
|
|
14
|
+
use super::wire::ActorState;
|
|
15
|
+
use crate::node::{Actor, ChunkPos};
|
|
16
|
+
use crate::{CallError, Ctx, decode};
|
|
17
|
+
|
|
18
|
+
/// Blocks in a chunk's edge, for the engine pose's world coordinates.
|
|
19
|
+
pub const CHUNK_BLOCKS: f64 = 16.0;
|
|
20
|
+
/// The engine pose's length.
|
|
21
|
+
pub const POSE_BYTES: usize = 48;
|
|
22
|
+
|
|
23
|
+
/// One player near the place, as last seen.
|
|
24
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
25
|
+
pub struct Seen {
|
|
26
|
+
pub player: u64,
|
|
27
|
+
pub uuid: String,
|
|
28
|
+
pub x: f64,
|
|
29
|
+
pub y: f64,
|
|
30
|
+
pub z: f64,
|
|
31
|
+
/// Placed from the actor's state, not its chunk's centre.
|
|
32
|
+
pub precise: bool,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Where an actor is, in chunks, and whether that came from its state.
|
|
36
|
+
pub fn position_of(actor: &Actor) -> (f64, f64, f64, bool) {
|
|
37
|
+
if let Some(state) = actor.state.as_deref() {
|
|
38
|
+
if let Ok(s) = decode::<ActorState>(state) {
|
|
39
|
+
return (s.x, s.y, s.z, true);
|
|
40
|
+
}
|
|
41
|
+
if state.len() >= POSE_BYTES {
|
|
42
|
+
let f = |i: usize| f32::from_le_bytes([state[i], state[i + 1], state[i + 2], state[i + 3]]) as f64;
|
|
43
|
+
let (x, y, z) = (f(0), f(4), f(8));
|
|
44
|
+
if x.is_finite() && y.is_finite() && z.is_finite() {
|
|
45
|
+
return (x / CHUNK_BLOCKS, y / CHUNK_BLOCKS, z / CHUNK_BLOCKS, true);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
(actor.chunk.x as f64 + 0.5, actor.chunk.y as f64 + 0.5, actor.chunk.z as f64 + 0.5, false)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Every player among `actors`, once each (the first actor a player has).
|
|
53
|
+
pub fn players_among(actors: &[Actor]) -> Vec<Seen> {
|
|
54
|
+
let mut out: Vec<Seen> = Vec::new();
|
|
55
|
+
for a in actors {
|
|
56
|
+
let Some(player) = a.player else { continue };
|
|
57
|
+
if out.iter().any(|s| s.player == player) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
let (x, y, z, precise) = position_of(a);
|
|
61
|
+
out.push(Seen { player, uuid: a.uuid.clone(), x, y, z, precise });
|
|
62
|
+
}
|
|
63
|
+
out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// The players within `radius` chunks (at most 3 across, 1 up and down) of `center`.
|
|
67
|
+
pub fn nearby(ctx: &Ctx, center: ChunkPos, radius: u8) -> Result<Vec<Seen>, CallError> {
|
|
68
|
+
Ok(players_among(&ctx.world().actors_near(center, radius, 1)?))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Distance on the ground (x and z), in chunks.
|
|
72
|
+
pub fn ground_distance(a: (f64, f64), b: (f64, f64)) -> f64 {
|
|
73
|
+
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Players' recent positions and speeds, from one scan to the next.
|
|
77
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
|
78
|
+
pub struct Tracker {
|
|
79
|
+
#[serde(default)]
|
|
80
|
+
pub last: BTreeMap<u64, Track>,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
84
|
+
pub struct Track {
|
|
85
|
+
pub x: f64,
|
|
86
|
+
pub z: f64,
|
|
87
|
+
pub at: u64,
|
|
88
|
+
/// Chunks a second between the last two scans; 0 for the first.
|
|
89
|
+
pub speed: f64,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
impl Tracker {
|
|
93
|
+
/// Takes a scan at `now`; players not in it are forgotten. Returns each player's speed.
|
|
94
|
+
pub fn update(&mut self, seen: &[Seen], now: u64) -> BTreeMap<u64, f64> {
|
|
95
|
+
let mut next = BTreeMap::new();
|
|
96
|
+
for s in seen {
|
|
97
|
+
let speed = match self.last.get(&s.player) {
|
|
98
|
+
Some(t) if now > t.at => ground_distance((t.x, t.z), (s.x, s.z)) / ((now - t.at) as f64 / 1_000.0),
|
|
99
|
+
_ => 0.0,
|
|
100
|
+
};
|
|
101
|
+
next.insert(s.player, Track { x: s.x, z: s.z, at: now, speed });
|
|
102
|
+
}
|
|
103
|
+
self.last = next;
|
|
104
|
+
self.last.iter().map(|(p, t)| (*p, t.speed)).collect()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
pub fn position(&self, player: u64) -> Option<(f64, f64)> {
|
|
108
|
+
self.last.get(&player).map(|t| (t.x, t.z))
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#[cfg(test)]
|
|
113
|
+
mod tests {
|
|
114
|
+
use super::*;
|
|
115
|
+
use crate::encode;
|
|
116
|
+
|
|
117
|
+
fn actor(player: Option<u64>, chunk: (i64, i64, i64), state: Option<Vec<u8>>) -> Actor {
|
|
118
|
+
Actor { uuid: format!("a{player:?}"), player, chunk: ChunkPos::new(chunk.0, chunk.1, chunk.2), state }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#[test]
|
|
122
|
+
fn a_kit_state_or_an_engine_pose_places_a_player_exactly_and_anything_else_at_the_chunk_centre() {
|
|
123
|
+
let kit = encode(&ActorState::new("player", 1, 2.25, -3.5)).unwrap();
|
|
124
|
+
assert_eq!(position_of(&actor(Some(1), (2, 0, -4), Some(kit))), (2.25, 0.0, -3.5, true));
|
|
125
|
+
let mut pose = vec![0u8; POSE_BYTES];
|
|
126
|
+
pose[0..4].copy_from_slice(&40.0f32.to_le_bytes());
|
|
127
|
+
pose[8..12].copy_from_slice(&(-8.0f32).to_le_bytes());
|
|
128
|
+
assert_eq!(position_of(&actor(Some(2), (2, 0, -1), Some(pose))), (2.5, 0.0, -0.5, true));
|
|
129
|
+
assert_eq!(position_of(&actor(Some(3), (1, 0, 1), Some(b"opaque".to_vec()))), (1.5, 0.5, 1.5, false));
|
|
130
|
+
let seen = players_among(&[actor(None, (0, 0, 0), None), actor(Some(4), (0, 0, 0), None), actor(Some(4), (9, 0, 9), None)]);
|
|
131
|
+
assert_eq!(seen.len(), 1, "an NPC is not a player, and a player counts once");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#[test]
|
|
135
|
+
fn a_tracker_tells_each_players_speed() {
|
|
136
|
+
let mut t = Tracker::default();
|
|
137
|
+
let s = |x: f64| vec![Seen { player: 1, uuid: "u".into(), x, y: 0.0, z: 0.0, precise: true }];
|
|
138
|
+
assert_eq!(t.update(&s(0.0), 1_000)[&1], 0.0);
|
|
139
|
+
assert_eq!(t.update(&s(3.0), 2_000)[&1], 3.0);
|
|
140
|
+
assert!(t.update(&[], 3_000).is_empty(), "gone from the scan, forgotten");
|
|
141
|
+
}
|
|
142
|
+
}
|