@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,1151 @@
|
|
|
1
|
+
//! Speed and a contested ball, for two node types from one crate. Positions come from the
|
|
2
|
+
//! players' clients (`pos { x, z }`, a few times a second), and the movement warden
|
|
3
|
+
//! (`ckx_sdk::kit::play::movement`) keeps what the rules allow: the hub times and referees the
|
|
4
|
+
//! positions it kept, not the ones a client claimed. Positions are the hub's own units (blocks,
|
|
5
|
+
//! say), from the course's or the pitch's origin.
|
|
6
|
+
//!
|
|
7
|
+
//! - **`race`**, keyed by its race: a course of gates passed in order (`ckx_sdk::kit::play::timing`),
|
|
8
|
+
//! a countdown, laps and splits, places in finishing order, the rest given `finish_grace_ms`
|
|
9
|
+
//! after the winner, and a ghost of the winner's run. With a `board`, the finishers' times go to
|
|
10
|
+
//! that `leaderboard` (configure it with `order: "low"`; the manifest lets `race` call
|
|
11
|
+
//! `leaderboard`). Endpoints: `join`, `leave` (before the start), `start` (the first to join,
|
|
12
|
+
//! or a developer), `pos { x, z }` (answers the position kept), `ghost` (the winner's track),
|
|
13
|
+
//! `state` (anyone). Topic `race`: `snapshot` messages as racers move on, `event` messages
|
|
14
|
+
//! (`type`: `go`, `timing` with its `stage`, `violation`, `finished`); timing is also realtime
|
|
15
|
+
//! event 97 with an `anchor`.
|
|
16
|
+
//! - **`ball`**, keyed by its match: two teams, a ball the hub moves (passes, shots, friction,
|
|
17
|
+
//! the side walls), possession (claim a loose ball near it, or take it from an opponent whose
|
|
18
|
+
//! protection is over), a goal in each end wall, first to `score_to_win` or the better score
|
|
19
|
+
//! when the time is up. Endpoints: `join { team }`, `start`, `pos { x, z }`, `claim`,
|
|
20
|
+
//! `pass { to }`, `shoot { x, z }`, `state`. Topic `ball`: `snapshot` messages five times a
|
|
21
|
+
//! second while the ball moves, `event` messages (`type`: `claimed`, `passed`, `shot`, `goal`,
|
|
22
|
+
//! `finished`). With an `anchor`, players near the pitch also see the ball as a server-driven
|
|
23
|
+
//! actor (`kind: "ball"`) and its possession changes as realtime event 100.
|
|
24
|
+
//!
|
|
25
|
+
//! A type runs as the one its seed names (`{"mode": "ball", ...}`), or as the one its name is.
|
|
26
|
+
//! Both speak the kit protocol (`ckx_sdk::kit`): `snapshot { topic }` answers each topic;
|
|
27
|
+
//! changes may carry an `action_id`; refusals carry `kit:<code>:` codes.
|
|
28
|
+
|
|
29
|
+
use ckx_sdk::kit::play::combat::Cooldowns;
|
|
30
|
+
use ckx_sdk::kit::play::movement::{Rules, Warden};
|
|
31
|
+
use ckx_sdk::kit::play::timing::{Course, Gate, GhostTrack, RunTimer, TimingEvent};
|
|
32
|
+
use ckx_sdk::kit::presence::CHUNK_BLOCKS;
|
|
33
|
+
use ckx_sdk::kit::wire::{self, ActorState};
|
|
34
|
+
use ckx_sdk::kit::{self, Publisher, Receipts, codes};
|
|
35
|
+
use ckx_sdk::prelude::*;
|
|
36
|
+
use serde_json::{Value, json};
|
|
37
|
+
|
|
38
|
+
pub const RACE_TOPIC: &str = "race";
|
|
39
|
+
pub const BALL_TOPIC: &str = "ball";
|
|
40
|
+
pub const TICK_MS: u64 = 100;
|
|
41
|
+
const PUBLISH_EVERY: u64 = 2;
|
|
42
|
+
const EVENT_DISTANCE: u8 = 4;
|
|
43
|
+
|
|
44
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
|
45
|
+
#[serde(rename_all = "snake_case")]
|
|
46
|
+
pub enum Mode {
|
|
47
|
+
Race,
|
|
48
|
+
Ball,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// The type this instance runs as: its seed's `mode`, or its name.
|
|
52
|
+
pub fn mode_of(node_type: &str, seed: &[u8]) -> Result<Mode> {
|
|
53
|
+
#[derive(Deserialize)]
|
|
54
|
+
struct Named {
|
|
55
|
+
#[serde(default)]
|
|
56
|
+
mode: Option<Mode>,
|
|
57
|
+
}
|
|
58
|
+
if !seed.is_empty() {
|
|
59
|
+
let named: Named = serde_json::from_slice(seed).map_err(|e| Error::new(format!("seed is not a JSON config: {e}")))?;
|
|
60
|
+
if let Some(mode) = named.mode {
|
|
61
|
+
return Ok(mode);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
match node_type {
|
|
65
|
+
"race" => Ok(Mode::Race),
|
|
66
|
+
"ball" => Ok(Mode::Ball),
|
|
67
|
+
other => Err(Error::new(format!(
|
|
68
|
+
"the racing crate runs a `race` or a `ball`; give `{other}` a seed with its mode: {{\"mode\": \"ball\"}}"
|
|
69
|
+
))),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fn config<T: serde::de::DeserializeOwned + Default>(seed: &[u8]) -> Result<T> {
|
|
74
|
+
if seed.is_empty() {
|
|
75
|
+
return Ok(T::default());
|
|
76
|
+
}
|
|
77
|
+
serde_json::from_slice(seed).map_err(|e| Error::new(format!("seed is not a JSON config: {e}")))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn with_type(kind: &str, extra: Value) -> Value {
|
|
81
|
+
let mut e = json!({ "type": kind });
|
|
82
|
+
if let (Some(e), Some(x)) = (e.as_object_mut(), extra.as_object()) {
|
|
83
|
+
e.extend(x.clone());
|
|
84
|
+
}
|
|
85
|
+
e
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#[derive(Deserialize)]
|
|
89
|
+
struct Pos {
|
|
90
|
+
x: f64,
|
|
91
|
+
z: f64,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- race ----
|
|
95
|
+
|
|
96
|
+
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
97
|
+
#[serde(default)]
|
|
98
|
+
pub struct RaceConfig {
|
|
99
|
+
/// The course's name in timing events.
|
|
100
|
+
pub course_id: String,
|
|
101
|
+
pub course: Course,
|
|
102
|
+
pub countdown_ms: u64,
|
|
103
|
+
pub racers_max: usize,
|
|
104
|
+
pub rules: Rules,
|
|
105
|
+
/// How long the rest have once the winner is in.
|
|
106
|
+
pub finish_grace_ms: u64,
|
|
107
|
+
pub board: Option<String>,
|
|
108
|
+
/// The chunk the course's origin is at, for realtime events.
|
|
109
|
+
pub anchor: Option<(i64, i64, i64)>,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
impl Default for RaceConfig {
|
|
113
|
+
fn default() -> Self {
|
|
114
|
+
let gate = |x: f32, z: f32| Gate { x, z, radius: 3.0 };
|
|
115
|
+
Self {
|
|
116
|
+
course_id: "loop".into(),
|
|
117
|
+
course: Course { gates: vec![gate(0.0, 0.0), gate(30.0, 0.0), gate(15.0, 25.0)], laps: 2 },
|
|
118
|
+
countdown_ms: 3_000,
|
|
119
|
+
racers_max: 8,
|
|
120
|
+
rules: Rules { max_speed: 12.0, teleport: 20.0, bounds: None, grace: 1.25 },
|
|
121
|
+
finish_grace_ms: 30_000,
|
|
122
|
+
board: None,
|
|
123
|
+
anchor: None,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
129
|
+
#[serde(rename_all = "snake_case")]
|
|
130
|
+
pub enum RacePhase {
|
|
131
|
+
#[default]
|
|
132
|
+
Lobby,
|
|
133
|
+
Countdown,
|
|
134
|
+
Racing,
|
|
135
|
+
Finished,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
139
|
+
pub struct Racer {
|
|
140
|
+
pub player: u64,
|
|
141
|
+
pub timer: RunTimer,
|
|
142
|
+
pub warden: Warden,
|
|
143
|
+
pub ghost: GhostTrack,
|
|
144
|
+
pub place: Option<u32>,
|
|
145
|
+
pub total_ms: Option<u64>,
|
|
146
|
+
pub did_not_finish: bool,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
150
|
+
pub struct RacerView {
|
|
151
|
+
pub player: u64,
|
|
152
|
+
pub x: f64,
|
|
153
|
+
pub z: f64,
|
|
154
|
+
pub lap: u32,
|
|
155
|
+
pub next_gate: usize,
|
|
156
|
+
pub best_lap_ms: Option<u64>,
|
|
157
|
+
pub place: Option<u32>,
|
|
158
|
+
pub total_ms: Option<u64>,
|
|
159
|
+
pub did_not_finish: bool,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// The race as everyone sees it.
|
|
163
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
164
|
+
pub struct RaceView {
|
|
165
|
+
pub phase: RacePhase,
|
|
166
|
+
pub course_id: String,
|
|
167
|
+
pub gates: Vec<Gate>,
|
|
168
|
+
pub laps: u32,
|
|
169
|
+
pub go_at: Option<u64>,
|
|
170
|
+
pub closes_at: Option<u64>,
|
|
171
|
+
pub racers: Vec<RacerView>,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// The winner's run, to race against.
|
|
175
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
176
|
+
pub struct Ghost {
|
|
177
|
+
pub player: u64,
|
|
178
|
+
pub total_ms: u64,
|
|
179
|
+
/// `(ms from the start, x, y, z, yaw)`.
|
|
180
|
+
pub frames: Vec<(u32, f32, f32, f32, f32)>,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
184
|
+
pub struct Race {
|
|
185
|
+
pub config: RaceConfig,
|
|
186
|
+
pub phase: RacePhase,
|
|
187
|
+
pub racers: Vec<Racer>,
|
|
188
|
+
pub go_at: Option<u64>,
|
|
189
|
+
pub closes_at: Option<u64>,
|
|
190
|
+
pub ghost: Option<Ghost>,
|
|
191
|
+
#[serde(skip)]
|
|
192
|
+
pub events: Vec<Value>,
|
|
193
|
+
#[serde(skip)]
|
|
194
|
+
pub moved: bool,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
impl Race {
|
|
198
|
+
pub fn view(&self) -> RaceView {
|
|
199
|
+
RaceView {
|
|
200
|
+
phase: self.phase,
|
|
201
|
+
course_id: self.config.course_id.clone(),
|
|
202
|
+
gates: self.config.course.gates.clone(),
|
|
203
|
+
laps: self.config.course.laps,
|
|
204
|
+
go_at: self.go_at,
|
|
205
|
+
closes_at: self.closes_at,
|
|
206
|
+
racers: self
|
|
207
|
+
.racers
|
|
208
|
+
.iter()
|
|
209
|
+
.map(|r| {
|
|
210
|
+
let (x, z) = r.warden.position().unwrap_or((0.0, 0.0));
|
|
211
|
+
RacerView {
|
|
212
|
+
player: r.player,
|
|
213
|
+
x,
|
|
214
|
+
z,
|
|
215
|
+
lap: r.timer.lap,
|
|
216
|
+
next_gate: r.timer.next_gate,
|
|
217
|
+
best_lap_ms: (r.timer.best_lap_ms > 0).then_some(r.timer.best_lap_ms),
|
|
218
|
+
place: r.place,
|
|
219
|
+
total_ms: r.total_ms,
|
|
220
|
+
did_not_finish: r.did_not_finish,
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
.collect(),
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
fn start_line(&self) -> (f64, f64) {
|
|
228
|
+
self.config.course.gates.first().map_or((0.0, 0.0), |g| (f64::from(g.x), f64::from(g.z)))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
pub fn join(&mut self, player: u64, now: u64) -> Result<()> {
|
|
232
|
+
if self.phase != RacePhase::Lobby {
|
|
233
|
+
return Err(kit::error(codes::STARTED, "the race is under way"));
|
|
234
|
+
}
|
|
235
|
+
if self.racers.iter().any(|r| r.player == player) {
|
|
236
|
+
return Ok(());
|
|
237
|
+
}
|
|
238
|
+
if self.racers.len() >= self.config.racers_max {
|
|
239
|
+
return Err(kit::error(codes::FULL, "the grid is full"));
|
|
240
|
+
}
|
|
241
|
+
let at = self.start_line();
|
|
242
|
+
self.racers.push(Racer {
|
|
243
|
+
player,
|
|
244
|
+
timer: RunTimer::default(),
|
|
245
|
+
warden: Warden::at(at, now),
|
|
246
|
+
ghost: GhostTrack::default(),
|
|
247
|
+
place: None,
|
|
248
|
+
total_ms: None,
|
|
249
|
+
did_not_finish: false,
|
|
250
|
+
});
|
|
251
|
+
self.moved = true;
|
|
252
|
+
Ok(())
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
pub fn leave(&mut self, player: u64) -> Result<()> {
|
|
256
|
+
if self.phase != RacePhase::Lobby {
|
|
257
|
+
return Err(kit::error(codes::STARTED, "the race is under way"));
|
|
258
|
+
}
|
|
259
|
+
self.racers.retain(|r| r.player != player);
|
|
260
|
+
self.moved = true;
|
|
261
|
+
Ok(())
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
pub fn start(&mut self, now: u64) -> Result<()> {
|
|
265
|
+
if self.phase != RacePhase::Lobby {
|
|
266
|
+
return Err(kit::error(codes::STARTED, "the race is under way"));
|
|
267
|
+
}
|
|
268
|
+
if self.racers.is_empty() || self.config.course.gates.is_empty() {
|
|
269
|
+
return Err(kit::error(codes::NOT_READY, "a race needs racers and a course"));
|
|
270
|
+
}
|
|
271
|
+
self.phase = RacePhase::Countdown;
|
|
272
|
+
self.go_at = Some(now + self.config.countdown_ms);
|
|
273
|
+
self.moved = true;
|
|
274
|
+
Ok(())
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/// The start: every racer on the line starts the clock now.
|
|
278
|
+
pub fn go(&mut self, now: u64) {
|
|
279
|
+
if self.phase != RacePhase::Countdown {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
self.phase = RacePhase::Racing;
|
|
283
|
+
self.events.push(with_type("go", json!({ "at": now })));
|
|
284
|
+
for i in 0..self.racers.len() {
|
|
285
|
+
if let Some(at) = self.racers[i].warden.position() {
|
|
286
|
+
self.feed(i, at, now);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
self.moved = true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/// A reported position. Returns the position kept.
|
|
293
|
+
pub fn moved(&mut self, player: u64, to: (f64, f64), now: u64) -> Result<(f64, f64)> {
|
|
294
|
+
let i = self.racers.iter().position(|r| r.player == player).ok_or_else(|| kit::error(codes::NOT_MEMBER, "join the race first"))?;
|
|
295
|
+
let verdict = self.racers[i].warden.check(&self.config.rules, to, now);
|
|
296
|
+
if let Some(v) = &verdict.violation {
|
|
297
|
+
self.events.push(with_type("violation", json!({ "player": player, "what": v.kind.as_str(), "detail": v.detail })));
|
|
298
|
+
}
|
|
299
|
+
if self.phase == RacePhase::Racing {
|
|
300
|
+
self.feed(i, verdict.at, now);
|
|
301
|
+
}
|
|
302
|
+
self.moved = true;
|
|
303
|
+
Ok(verdict.at)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
fn feed(&mut self, i: usize, at: (f64, f64), now: u64) {
|
|
307
|
+
let r = &mut self.racers[i];
|
|
308
|
+
if r.place.is_some() || r.did_not_finish {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if r.timer.started_at_ms > 0 {
|
|
312
|
+
r.ghost.record(now, at.0 as f32, 0.0, at.1 as f32, 0.0);
|
|
313
|
+
}
|
|
314
|
+
let events = r.timer.feed(&self.config.course, (at.0 as f32, at.1 as f32), now);
|
|
315
|
+
let player = r.player;
|
|
316
|
+
for e in events {
|
|
317
|
+
let (kind, checkpoint, lap, ms) = match e {
|
|
318
|
+
TimingEvent::Started { .. } => {
|
|
319
|
+
let r = &mut self.racers[i];
|
|
320
|
+
r.ghost = GhostTrack::default();
|
|
321
|
+
r.ghost.record(now, at.0 as f32, 0.0, at.1 as f32, 0.0);
|
|
322
|
+
("started", 0, 1, 0)
|
|
323
|
+
}
|
|
324
|
+
TimingEvent::Checkpoint { gate, split_ms } => ("checkpoint", gate as u32, self.racers[i].timer.lap, split_ms),
|
|
325
|
+
TimingEvent::LapCompleted { lap, lap_ms, .. } => ("lap", 0, lap, lap_ms),
|
|
326
|
+
TimingEvent::Finished { total_ms, .. } => {
|
|
327
|
+
self.finish(i, total_ms, now);
|
|
328
|
+
("finished", 0, self.config.course.laps, total_ms)
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
self.events.push(with_type(
|
|
332
|
+
"timing",
|
|
333
|
+
json!({ "stage": kind, "player": player, "checkpoint": checkpoint, "lap": lap, "elapsed_ms": ms }),
|
|
334
|
+
));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
fn finish(&mut self, i: usize, total_ms: u64, now: u64) {
|
|
339
|
+
let place = self.racers.iter().filter(|r| r.place.is_some()).count() as u32 + 1;
|
|
340
|
+
let r = &mut self.racers[i];
|
|
341
|
+
r.place = Some(place);
|
|
342
|
+
r.total_ms = Some(total_ms);
|
|
343
|
+
if place == 1 {
|
|
344
|
+
self.ghost = Some(Ghost { player: r.player, total_ms, frames: r.ghost.frames.clone() });
|
|
345
|
+
self.closes_at = Some(now + self.config.finish_grace_ms);
|
|
346
|
+
}
|
|
347
|
+
if self.racers.iter().all(|r| r.place.is_some()) {
|
|
348
|
+
self.close();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/// Ends the race: whoever has not finished did not.
|
|
353
|
+
pub fn close(&mut self) {
|
|
354
|
+
if self.phase == RacePhase::Finished {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
self.phase = RacePhase::Finished;
|
|
358
|
+
for r in self.racers.iter_mut().filter(|r| r.place.is_none()) {
|
|
359
|
+
r.did_not_finish = true;
|
|
360
|
+
}
|
|
361
|
+
let results: Vec<Value> =
|
|
362
|
+
self.racers.iter().map(|r| json!({ "player": r.player, "place": r.place, "total_ms": r.total_ms })).collect();
|
|
363
|
+
self.events.push(with_type("finished", json!({ "results": results })));
|
|
364
|
+
self.moved = true;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
369
|
+
pub struct RaceHub {
|
|
370
|
+
#[serde(flatten)]
|
|
371
|
+
pub race: Race,
|
|
372
|
+
#[serde(default)]
|
|
373
|
+
pub kit: Publisher,
|
|
374
|
+
#[serde(default)]
|
|
375
|
+
pub receipts: Receipts,
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
impl RaceHub {
|
|
379
|
+
fn act(&mut self, ctx: &Ctx, call: &Call<'_>, now: u64) -> Result<Vec<u8>> {
|
|
380
|
+
let out = match call.method {
|
|
381
|
+
"join" => {
|
|
382
|
+
self.race.join(call.player()?, now)?;
|
|
383
|
+
encode(&true)
|
|
384
|
+
}
|
|
385
|
+
"leave" => {
|
|
386
|
+
self.race.leave(call.player()?)?;
|
|
387
|
+
encode(&true)
|
|
388
|
+
}
|
|
389
|
+
"start" => {
|
|
390
|
+
let allowed = match call.caller {
|
|
391
|
+
Caller::Developer(_) => true,
|
|
392
|
+
Caller::Player(p) => self.race.racers.first().map(|r| r.player) == Some(p),
|
|
393
|
+
_ => false,
|
|
394
|
+
};
|
|
395
|
+
if !allowed {
|
|
396
|
+
return Err(kit::error(codes::FORBIDDEN, "the first to join, or a developer, starts it"));
|
|
397
|
+
}
|
|
398
|
+
self.race.start(now)?;
|
|
399
|
+
ctx.timer_after("go", self.race.config.countdown_ms.max(10))?;
|
|
400
|
+
encode(&true)
|
|
401
|
+
}
|
|
402
|
+
"pos" => {
|
|
403
|
+
let p: Pos = kit::args(call)?;
|
|
404
|
+
let kept = self.race.moved(call.player()?, (p.x, p.z), now)?;
|
|
405
|
+
encode(&kept)
|
|
406
|
+
}
|
|
407
|
+
other => return Err(Error::unknown_method(other)),
|
|
408
|
+
};
|
|
409
|
+
self.after(ctx)?;
|
|
410
|
+
out
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
fn after(&mut self, ctx: &Ctx) -> Result<()> {
|
|
414
|
+
if let Some(close) = self.race.closes_at
|
|
415
|
+
&& self.race.phase == RacePhase::Racing
|
|
416
|
+
&& !ctx.timers().iter().any(|(n, _)| n == "close")
|
|
417
|
+
{
|
|
418
|
+
ctx.timer_after("close", close.saturating_sub(ctx.now_ms()).max(10))?;
|
|
419
|
+
}
|
|
420
|
+
let finished = self.race.events.iter().any(|e| e["type"] == "finished");
|
|
421
|
+
for e in std::mem::take(&mut self.race.events) {
|
|
422
|
+
self.kit.event(ctx, RACE_TOPIC, &e)?;
|
|
423
|
+
self.realtime(ctx, &e);
|
|
424
|
+
}
|
|
425
|
+
if finished {
|
|
426
|
+
ctx.cancel_timer("close");
|
|
427
|
+
self.post(ctx);
|
|
428
|
+
ctx.persist_now();
|
|
429
|
+
}
|
|
430
|
+
if std::mem::take(&mut self.race.moved) {
|
|
431
|
+
self.kit.snapshot(ctx, RACE_TOPIC, &self.race.view())?;
|
|
432
|
+
}
|
|
433
|
+
Ok(())
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/// The finishers' times, to the board.
|
|
437
|
+
fn post(&self, ctx: &Ctx) {
|
|
438
|
+
let Some(board) = &self.race.config.board else { return };
|
|
439
|
+
for r in &self.race.racers {
|
|
440
|
+
if let Some(ms) = r.total_ms {
|
|
441
|
+
let submit = json!({ "player": r.player, "score": ms, "action_id": format!("{}:{}", ctx.key, r.player) });
|
|
442
|
+
let sent =
|
|
443
|
+
encode(&submit).map_err(|Error(e)| e).and_then(|b| ctx.send("leaderboard", board, "submit", &b).map_err(|e| e.message));
|
|
444
|
+
if let Err(why) = sent {
|
|
445
|
+
ctx.log(Level::Warn, &format!("a time did not reach board {board}: {why}"));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
fn realtime(&self, ctx: &Ctx, e: &Value) {
|
|
452
|
+
let (Some((x, y, z)), Some("timing")) = (self.race.config.anchor, e["type"].as_str()) else { return };
|
|
453
|
+
let body = wire::RaceTiming {
|
|
454
|
+
kind: e["stage"].as_str().unwrap_or_default().into(),
|
|
455
|
+
course_id: self.race.config.course_id.clone(),
|
|
456
|
+
user_id: e["player"].as_u64().unwrap_or(0).to_string(),
|
|
457
|
+
checkpoint: e["checkpoint"].as_u64().unwrap_or(0) as u32,
|
|
458
|
+
lap: e["lap"].as_u64().unwrap_or(0) as u32,
|
|
459
|
+
elapsed_ms: e["elapsed_ms"].as_u64().unwrap_or(0),
|
|
460
|
+
};
|
|
461
|
+
let actor = actor_id(&format!("race:{}", ctx.key));
|
|
462
|
+
if let Err(Error(why)) = wire::emit_event(ctx, ChunkPos::new(x, y, z), EVENT_DISTANCE, &actor, wire::RACE_TIMING, &body) {
|
|
463
|
+
ctx.log(Level::Warn, &format!("a timing event did not go out: {why}"));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---- ball ----
|
|
469
|
+
|
|
470
|
+
pub const TEAMS: [&str; 2] = ["red", "blue"];
|
|
471
|
+
|
|
472
|
+
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
473
|
+
#[serde(default)]
|
|
474
|
+
pub struct BallConfig {
|
|
475
|
+
pub width: f64,
|
|
476
|
+
pub depth: f64,
|
|
477
|
+
/// The goal's width in each end wall, centred.
|
|
478
|
+
pub goal_width: f64,
|
|
479
|
+
pub team_max: usize,
|
|
480
|
+
pub claim_radius: f64,
|
|
481
|
+
/// How long a new holder cannot be robbed.
|
|
482
|
+
pub protect_ms: u64,
|
|
483
|
+
pub pass_speed: f64,
|
|
484
|
+
pub shoot_speed: f64,
|
|
485
|
+
/// The share of its speed a loose ball loses each second.
|
|
486
|
+
pub friction: f64,
|
|
487
|
+
pub score_to_win: u32,
|
|
488
|
+
pub duration_ms: u64,
|
|
489
|
+
pub max_speed: f64,
|
|
490
|
+
pub teleport: f64,
|
|
491
|
+
/// The chunk the pitch's origin is at: the ball is shown there, in blocks.
|
|
492
|
+
pub anchor: Option<(i64, i64, i64)>,
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
impl Default for BallConfig {
|
|
496
|
+
fn default() -> Self {
|
|
497
|
+
Self {
|
|
498
|
+
width: 60.0,
|
|
499
|
+
depth: 40.0,
|
|
500
|
+
goal_width: 12.0,
|
|
501
|
+
team_max: 5,
|
|
502
|
+
claim_radius: 1.5,
|
|
503
|
+
protect_ms: 1_500,
|
|
504
|
+
pass_speed: 18.0,
|
|
505
|
+
shoot_speed: 28.0,
|
|
506
|
+
friction: 0.8,
|
|
507
|
+
score_to_win: 3,
|
|
508
|
+
duration_ms: 5 * 60_000,
|
|
509
|
+
max_speed: 9.0,
|
|
510
|
+
teleport: 15.0,
|
|
511
|
+
anchor: None,
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
517
|
+
#[serde(rename_all = "snake_case")]
|
|
518
|
+
pub enum MatchPhase {
|
|
519
|
+
#[default]
|
|
520
|
+
Lobby,
|
|
521
|
+
Playing,
|
|
522
|
+
Finished,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
526
|
+
pub struct Footballer {
|
|
527
|
+
pub player: u64,
|
|
528
|
+
pub team: usize,
|
|
529
|
+
pub warden: Warden,
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
|
533
|
+
pub struct BallState {
|
|
534
|
+
pub x: f64,
|
|
535
|
+
pub z: f64,
|
|
536
|
+
pub vx: f64,
|
|
537
|
+
pub vz: f64,
|
|
538
|
+
pub holder: Option<u64>,
|
|
539
|
+
pub held_since: u64,
|
|
540
|
+
pub last_touch: Option<u64>,
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
544
|
+
pub struct PlayerView {
|
|
545
|
+
pub player: u64,
|
|
546
|
+
pub team: String,
|
|
547
|
+
pub x: f64,
|
|
548
|
+
pub z: f64,
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/// The match as everyone sees it.
|
|
552
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
553
|
+
pub struct BallView {
|
|
554
|
+
pub phase: MatchPhase,
|
|
555
|
+
pub width: f64,
|
|
556
|
+
pub depth: f64,
|
|
557
|
+
pub goal_width: f64,
|
|
558
|
+
pub ball: BallState,
|
|
559
|
+
pub players: Vec<PlayerView>,
|
|
560
|
+
/// Red's, then blue's.
|
|
561
|
+
pub score: [u32; 2],
|
|
562
|
+
pub ends_at: Option<u64>,
|
|
563
|
+
pub winner: Option<String>,
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
#[derive(Deserialize)]
|
|
567
|
+
struct JoinTeam {
|
|
568
|
+
team: String,
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
#[derive(Deserialize)]
|
|
572
|
+
struct Pass {
|
|
573
|
+
to: u64,
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
577
|
+
pub struct Match {
|
|
578
|
+
pub config: BallConfig,
|
|
579
|
+
pub phase: MatchPhase,
|
|
580
|
+
pub players: Vec<Footballer>,
|
|
581
|
+
pub ball: BallState,
|
|
582
|
+
pub score: [u32; 2],
|
|
583
|
+
pub ends_at: Option<u64>,
|
|
584
|
+
pub winner: Option<usize>,
|
|
585
|
+
pub cooldowns: Cooldowns,
|
|
586
|
+
pub ticks: u64,
|
|
587
|
+
#[serde(skip)]
|
|
588
|
+
pub events: Vec<Value>,
|
|
589
|
+
#[serde(skip)]
|
|
590
|
+
pub moved: bool,
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {
|
|
594
|
+
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
impl Match {
|
|
598
|
+
pub fn view(&self) -> BallView {
|
|
599
|
+
BallView {
|
|
600
|
+
phase: self.phase,
|
|
601
|
+
width: self.config.width,
|
|
602
|
+
depth: self.config.depth,
|
|
603
|
+
goal_width: self.config.goal_width,
|
|
604
|
+
ball: self.ball.clone(),
|
|
605
|
+
players: self
|
|
606
|
+
.players
|
|
607
|
+
.iter()
|
|
608
|
+
.map(|p| {
|
|
609
|
+
let (x, z) = p.warden.position().unwrap_or((0.0, 0.0));
|
|
610
|
+
PlayerView { player: p.player, team: TEAMS[p.team].into(), x, z }
|
|
611
|
+
})
|
|
612
|
+
.collect(),
|
|
613
|
+
score: self.score,
|
|
614
|
+
ends_at: self.ends_at,
|
|
615
|
+
winner: self.winner.map(|t| TEAMS[t].to_string()),
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
fn rules(&self) -> Rules {
|
|
620
|
+
Rules {
|
|
621
|
+
max_speed: self.config.max_speed,
|
|
622
|
+
teleport: self.config.teleport,
|
|
623
|
+
bounds: Some((0.0, 0.0, self.config.width, self.config.depth)),
|
|
624
|
+
grace: 1.25,
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
fn at(&self, player: u64) -> Result<(usize, (f64, f64))> {
|
|
629
|
+
let i =
|
|
630
|
+
self.players.iter().position(|p| p.player == player).ok_or_else(|| kit::error(codes::NOT_MEMBER, "join the match first"))?;
|
|
631
|
+
Ok((i, self.players[i].warden.position().unwrap_or((0.0, 0.0))))
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
fn playing(&self) -> Result<()> {
|
|
635
|
+
match self.phase {
|
|
636
|
+
MatchPhase::Playing => Ok(()),
|
|
637
|
+
MatchPhase::Lobby => Err(kit::error(codes::NOT_READY, "the match has not started")),
|
|
638
|
+
MatchPhase::Finished => Err(kit::error(codes::FINISHED, "the match is over")),
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
fn kickoff(&mut self) {
|
|
643
|
+
self.ball = BallState { x: self.config.width / 2.0, z: self.config.depth / 2.0, ..BallState::default() };
|
|
644
|
+
self.moved = true;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
pub fn join(&mut self, player: u64, team: &str, now: u64) -> Result<()> {
|
|
648
|
+
if self.phase != MatchPhase::Lobby {
|
|
649
|
+
return Err(kit::error(codes::STARTED, "the match is under way"));
|
|
650
|
+
}
|
|
651
|
+
let t = TEAMS.iter().position(|x| *x == team).ok_or_else(|| kit::error(codes::BAD_REQUEST, "a team is red or blue"))?;
|
|
652
|
+
if self.players.iter().filter(|p| p.team == t && p.player != player).count() >= self.config.team_max {
|
|
653
|
+
return Err(kit::error(codes::FULL, format!("{team} is full")));
|
|
654
|
+
}
|
|
655
|
+
let x = if t == 0 { self.config.width * 0.25 } else { self.config.width * 0.75 };
|
|
656
|
+
let at = (x, self.config.depth / 2.0);
|
|
657
|
+
match self.players.iter_mut().find(|p| p.player == player) {
|
|
658
|
+
Some(p) => p.team = t,
|
|
659
|
+
None => self.players.push(Footballer { player, team: t, warden: Warden::at(at, now) }),
|
|
660
|
+
}
|
|
661
|
+
self.moved = true;
|
|
662
|
+
Ok(())
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
pub fn start(&mut self, now: u64) -> Result<()> {
|
|
666
|
+
if self.phase == MatchPhase::Playing {
|
|
667
|
+
return Err(kit::error(codes::STARTED, "the match is under way"));
|
|
668
|
+
}
|
|
669
|
+
if !(0..2).all(|t| self.players.iter().any(|p| p.team == t)) {
|
|
670
|
+
return Err(kit::error(codes::NOT_READY, "each team needs a player"));
|
|
671
|
+
}
|
|
672
|
+
self.phase = MatchPhase::Playing;
|
|
673
|
+
self.score = [0, 0];
|
|
674
|
+
self.winner = None;
|
|
675
|
+
self.ends_at = Some(now + self.config.duration_ms);
|
|
676
|
+
self.kickoff();
|
|
677
|
+
Ok(())
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
pub fn moved(&mut self, player: u64, to: (f64, f64), now: u64) -> Result<(f64, f64)> {
|
|
681
|
+
let (i, _) = self.at(player)?;
|
|
682
|
+
let rules = self.rules();
|
|
683
|
+
let verdict = self.players[i].warden.check(&rules, to, now);
|
|
684
|
+
if let Some(v) = &verdict.violation {
|
|
685
|
+
self.events.push(with_type("violation", json!({ "player": player, "what": v.kind.as_str(), "detail": v.detail })));
|
|
686
|
+
}
|
|
687
|
+
if self.ball.holder == Some(player) {
|
|
688
|
+
(self.ball.x, self.ball.z) = verdict.at;
|
|
689
|
+
}
|
|
690
|
+
self.moved = true;
|
|
691
|
+
Ok(verdict.at)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
pub fn claim(&mut self, player: u64, now: u64) -> Result<()> {
|
|
695
|
+
self.playing()?;
|
|
696
|
+
let (i, at) = self.at(player)?;
|
|
697
|
+
if dist(at, (self.ball.x, self.ball.z)) > self.config.claim_radius {
|
|
698
|
+
return Err(kit::error(codes::OUT_OF_REACH, "the ball is too far"));
|
|
699
|
+
}
|
|
700
|
+
if !self.cooldowns.take(&player.to_string(), "claim", 500, now) {
|
|
701
|
+
return Err(kit::error(codes::COOLDOWN, "claiming too fast"));
|
|
702
|
+
}
|
|
703
|
+
let from = self.ball.holder;
|
|
704
|
+
if let Some(h) = from {
|
|
705
|
+
if h == player {
|
|
706
|
+
return Ok(());
|
|
707
|
+
}
|
|
708
|
+
let holder_team = self.players.iter().find(|p| p.player == h).map(|p| p.team);
|
|
709
|
+
if holder_team == Some(self.players[i].team) {
|
|
710
|
+
return Err(kit::error(codes::CONFLICT, "a teammate has it"));
|
|
711
|
+
}
|
|
712
|
+
if now.saturating_sub(self.ball.held_since) < self.config.protect_ms {
|
|
713
|
+
return Err(kit::error(codes::COOLDOWN, "they only just got it"));
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
self.ball = BallState { x: at.0, z: at.1, holder: Some(player), held_since: now, last_touch: Some(player), ..BallState::default() };
|
|
717
|
+
self.events.push(with_type("claimed", json!({ "player": player, "team": TEAMS[self.players[i].team], "from": from })));
|
|
718
|
+
self.moved = true;
|
|
719
|
+
Ok(())
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
fn release(&mut self, player: u64, toward: (f64, f64), speed: f64) -> Result<()> {
|
|
723
|
+
self.playing()?;
|
|
724
|
+
if self.ball.holder != Some(player) {
|
|
725
|
+
return Err(kit::error(codes::CONFLICT, "you do not have the ball"));
|
|
726
|
+
}
|
|
727
|
+
let (dx, dz) = (toward.0 - self.ball.x, toward.1 - self.ball.z);
|
|
728
|
+
let d = (dx * dx + dz * dz).sqrt();
|
|
729
|
+
let (ux, uz) = if d > 1e-6 { (dx / d, dz / d) } else { (1.0, 0.0) };
|
|
730
|
+
self.ball.vx = ux * speed;
|
|
731
|
+
self.ball.vz = uz * speed;
|
|
732
|
+
self.ball.holder = None;
|
|
733
|
+
self.ball.last_touch = Some(player);
|
|
734
|
+
self.moved = true;
|
|
735
|
+
Ok(())
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
pub fn pass(&mut self, player: u64, to: u64) -> Result<()> {
|
|
739
|
+
let (i, _) = self.at(player)?;
|
|
740
|
+
let (j, target) = self.at(to)?;
|
|
741
|
+
if i == j || self.players[i].team != self.players[j].team {
|
|
742
|
+
return Err(kit::error(codes::BAD_REQUEST, "pass to a teammate"));
|
|
743
|
+
}
|
|
744
|
+
self.release(player, target, self.config.pass_speed)?;
|
|
745
|
+
self.events.push(with_type("passed", json!({ "player": player, "to": to, "team": TEAMS[self.players[i].team] })));
|
|
746
|
+
Ok(())
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
pub fn shoot(&mut self, player: u64, aim: (f64, f64)) -> Result<()> {
|
|
750
|
+
let (i, _) = self.at(player)?;
|
|
751
|
+
self.release(player, aim, self.config.shoot_speed)?;
|
|
752
|
+
self.events.push(with_type("shot", json!({ "player": player, "team": TEAMS[self.players[i].team] })));
|
|
753
|
+
Ok(())
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/// One tick: the ball moves, the walls turn it, a goal counts, the clock runs. Returns
|
|
757
|
+
/// whether the match ran.
|
|
758
|
+
pub fn tick(&mut self, now: u64) -> bool {
|
|
759
|
+
if self.phase != MatchPhase::Playing {
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
self.ticks += 1;
|
|
763
|
+
let dt = TICK_MS as f64 / 1000.0;
|
|
764
|
+
let (w, d, half) = (self.config.width, self.config.depth, self.config.goal_width / 2.0);
|
|
765
|
+
if self.ball.holder.is_none() && (self.ball.vx != 0.0 || self.ball.vz != 0.0) {
|
|
766
|
+
let b = &mut self.ball;
|
|
767
|
+
b.x += b.vx * dt;
|
|
768
|
+
b.z += b.vz * dt;
|
|
769
|
+
let keep = (1.0 - self.config.friction * dt).max(0.0);
|
|
770
|
+
b.vx *= keep;
|
|
771
|
+
b.vz *= keep;
|
|
772
|
+
if b.vx.hypot(b.vz) < 0.2 {
|
|
773
|
+
(b.vx, b.vz) = (0.0, 0.0);
|
|
774
|
+
}
|
|
775
|
+
if b.z < 0.0 || b.z > d {
|
|
776
|
+
b.z = b.z.clamp(0.0, d);
|
|
777
|
+
b.vz = -b.vz;
|
|
778
|
+
}
|
|
779
|
+
let in_mouth = (b.z - d / 2.0).abs() <= half;
|
|
780
|
+
let scored = match (b.x < 0.0, b.x > w) {
|
|
781
|
+
(true, _) if in_mouth => Some(1),
|
|
782
|
+
(_, true) if in_mouth => Some(0),
|
|
783
|
+
(true, _) | (_, true) => {
|
|
784
|
+
b.x = b.x.clamp(0.0, w);
|
|
785
|
+
b.vx = -b.vx;
|
|
786
|
+
None
|
|
787
|
+
}
|
|
788
|
+
_ => None,
|
|
789
|
+
};
|
|
790
|
+
self.moved = true;
|
|
791
|
+
if let Some(team) = scored {
|
|
792
|
+
self.score[team] += 1;
|
|
793
|
+
let scorer = self.ball.last_touch;
|
|
794
|
+
self.events.push(with_type("goal", json!({ "team": TEAMS[team], "player": scorer, "score": self.score })));
|
|
795
|
+
self.kickoff();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
let decided = self.score.iter().position(|s| *s >= self.config.score_to_win);
|
|
799
|
+
if decided.is_some() || self.ends_at.is_some_and(|t| now >= t) {
|
|
800
|
+
self.phase = MatchPhase::Finished;
|
|
801
|
+
self.winner = decided.or(match self.score[0].cmp(&self.score[1]) {
|
|
802
|
+
std::cmp::Ordering::Greater => Some(0),
|
|
803
|
+
std::cmp::Ordering::Less => Some(1),
|
|
804
|
+
std::cmp::Ordering::Equal => None,
|
|
805
|
+
});
|
|
806
|
+
self.events.push(with_type("finished", json!({ "winner": self.winner.map(|t| TEAMS[t]), "score": self.score })));
|
|
807
|
+
self.moved = true;
|
|
808
|
+
}
|
|
809
|
+
true
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
814
|
+
pub struct BallHub {
|
|
815
|
+
#[serde(flatten)]
|
|
816
|
+
pub game: Match,
|
|
817
|
+
#[serde(default)]
|
|
818
|
+
pub kit: Publisher,
|
|
819
|
+
#[serde(default)]
|
|
820
|
+
pub receipts: Receipts,
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
impl BallHub {
|
|
824
|
+
fn act(&mut self, ctx: &Ctx, call: &Call<'_>, now: u64) -> Result<Vec<u8>> {
|
|
825
|
+
let g = &mut self.game;
|
|
826
|
+
let out = match call.method {
|
|
827
|
+
"join" => {
|
|
828
|
+
let j: JoinTeam = kit::args(call)?;
|
|
829
|
+
g.join(call.player()?, &j.team, now)?;
|
|
830
|
+
encode(&true)
|
|
831
|
+
}
|
|
832
|
+
"start" => {
|
|
833
|
+
let allowed = match call.caller {
|
|
834
|
+
Caller::Developer(_) => true,
|
|
835
|
+
Caller::Player(p) => g.players.first().map(|f| f.player) == Some(p),
|
|
836
|
+
_ => false,
|
|
837
|
+
};
|
|
838
|
+
if !allowed {
|
|
839
|
+
return Err(kit::error(codes::FORBIDDEN, "the first to join, or a developer, starts it"));
|
|
840
|
+
}
|
|
841
|
+
g.start(now)?;
|
|
842
|
+
ctx.timer_every("tick", TICK_MS)?;
|
|
843
|
+
encode(&true)
|
|
844
|
+
}
|
|
845
|
+
"pos" => {
|
|
846
|
+
let p: Pos = kit::args(call)?;
|
|
847
|
+
let kept = g.moved(call.player()?, (p.x, p.z), now)?;
|
|
848
|
+
self.flush(ctx, false)?;
|
|
849
|
+
return encode(&kept);
|
|
850
|
+
}
|
|
851
|
+
"claim" => {
|
|
852
|
+
g.claim(call.player()?, now)?;
|
|
853
|
+
encode(&true)
|
|
854
|
+
}
|
|
855
|
+
"pass" => {
|
|
856
|
+
let p: Pass = kit::args(call)?;
|
|
857
|
+
g.pass(call.player()?, p.to)?;
|
|
858
|
+
encode(&true)
|
|
859
|
+
}
|
|
860
|
+
"shoot" => {
|
|
861
|
+
let p: Pos = kit::args(call)?;
|
|
862
|
+
g.shoot(call.player()?, (p.x, p.z))?;
|
|
863
|
+
encode(&true)
|
|
864
|
+
}
|
|
865
|
+
other => return Err(Error::unknown_method(other)),
|
|
866
|
+
};
|
|
867
|
+
self.flush(ctx, true)?;
|
|
868
|
+
out
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
fn flush(&mut self, ctx: &Ctx, snapshot: bool) -> Result<()> {
|
|
872
|
+
for e in std::mem::take(&mut self.game.events) {
|
|
873
|
+
self.kit.event(ctx, BALL_TOPIC, &e)?;
|
|
874
|
+
self.possession(ctx, &e);
|
|
875
|
+
}
|
|
876
|
+
if snapshot && std::mem::take(&mut self.game.moved) {
|
|
877
|
+
self.kit.snapshot(ctx, BALL_TOPIC, &self.game.view())?;
|
|
878
|
+
}
|
|
879
|
+
Ok(())
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
fn anchor(&self) -> Option<(f64, f64, f64)> {
|
|
883
|
+
self.game.config.anchor.map(|(x, y, z)| (x as f64, y as f64, z as f64))
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
fn possession(&self, ctx: &Ctx, e: &Value) {
|
|
887
|
+
let Some((x, y, z)) = self.anchor() else { return };
|
|
888
|
+
let kind = match e["type"].as_str() {
|
|
889
|
+
Some("claimed") => "claimed",
|
|
890
|
+
Some("passed") => "passed",
|
|
891
|
+
Some("shot") => "loose",
|
|
892
|
+
Some("goal") => "goal",
|
|
893
|
+
_ => return,
|
|
894
|
+
};
|
|
895
|
+
let body = wire::PossessionChange {
|
|
896
|
+
kind: kind.into(),
|
|
897
|
+
holder_id: if kind == "claimed" { e["player"].as_u64().map(|p| p.to_string()) } else { None },
|
|
898
|
+
team: e["team"].as_str().map(String::from),
|
|
899
|
+
};
|
|
900
|
+
let at = ChunkPos::new(x as i64, y as i64, z as i64);
|
|
901
|
+
if let Err(Error(why)) = wire::emit_event(ctx, at, EVENT_DISTANCE, &actor_id(&format!("ball:{}", ctx.key)), wire::POSSESSION, &body)
|
|
902
|
+
{
|
|
903
|
+
ctx.log(Level::Warn, &format!("a possession event did not go out: {why}"));
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/// The ball as a server-driven actor near the pitch.
|
|
908
|
+
fn show_ball(&self, ctx: &Ctx) {
|
|
909
|
+
let Some((x, y, z)) = self.anchor() else { return };
|
|
910
|
+
let b = &self.game.ball;
|
|
911
|
+
let mut state = ActorState::new("ball", 1, x + b.x / CHUNK_BLOCKS, z + b.z / CHUNK_BLOCKS);
|
|
912
|
+
state.y = y + 0.1;
|
|
913
|
+
state.owner = b.holder;
|
|
914
|
+
if let Err(Error(why)) = wire::emit_actor_state(ctx, &actor_id(&format!("ball:{}", ctx.key)), EVENT_DISTANCE, &state) {
|
|
915
|
+
ctx.log(Level::Warn, &format!("the ball was not shown: {why}"));
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ---- the node ----
|
|
921
|
+
|
|
922
|
+
/// The crate's hub: one of the two types.
|
|
923
|
+
#[derive(Serialize, Deserialize, Debug)]
|
|
924
|
+
pub enum RacingNode {
|
|
925
|
+
Race(RaceHub),
|
|
926
|
+
Ball(BallHub),
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
impl Hub for RacingNode {
|
|
930
|
+
fn spawn(ctx: &Ctx, seed: &[u8]) -> Result<Self> {
|
|
931
|
+
Ok(match mode_of(&ctx.node_type, seed)? {
|
|
932
|
+
Mode::Race => Self::Race(RaceHub { race: Race { config: config(seed)?, ..Race::default() }, ..RaceHub::default() }),
|
|
933
|
+
Mode::Ball => {
|
|
934
|
+
let mut game = Match { config: config(seed)?, ..Match::default() };
|
|
935
|
+
game.kickoff();
|
|
936
|
+
Self::Ball(BallHub { game, ..BallHub::default() })
|
|
937
|
+
}
|
|
938
|
+
})
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
|
|
942
|
+
decode(snapshot)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
|
|
946
|
+
encode(self)
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
950
|
+
let now = ctx.now_ms();
|
|
951
|
+
match self {
|
|
952
|
+
Self::Race(h) => match call.method {
|
|
953
|
+
"state" => encode(&h.race.view()),
|
|
954
|
+
"ghost" => encode(&h.race.ghost),
|
|
955
|
+
kit::SNAPSHOT_METHOD => h.kit.snapshot_reply(ctx, RACE_TOPIC, &h.race.view()),
|
|
956
|
+
_ => Receipts::run(h, |h| &mut h.receipts, &call, now, |h| h.act(ctx, &call, now)),
|
|
957
|
+
},
|
|
958
|
+
Self::Ball(h) => match call.method {
|
|
959
|
+
"state" => encode(&h.game.view()),
|
|
960
|
+
kit::SNAPSHOT_METHOD => h.kit.snapshot_reply(ctx, BALL_TOPIC, &h.game.view()),
|
|
961
|
+
_ => Receipts::run(h, |h| &mut h.receipts, &call, now, |h| h.act(ctx, &call, now)),
|
|
962
|
+
},
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
|
|
967
|
+
let now = ctx.now_ms();
|
|
968
|
+
match (self, timer) {
|
|
969
|
+
(Self::Race(h), "go") => {
|
|
970
|
+
h.race.go(now);
|
|
971
|
+
h.after(ctx)
|
|
972
|
+
}
|
|
973
|
+
(Self::Race(h), "close") => {
|
|
974
|
+
h.race.close();
|
|
975
|
+
h.after(ctx)
|
|
976
|
+
}
|
|
977
|
+
(Self::Ball(h), "tick") => {
|
|
978
|
+
if !h.game.tick(now) {
|
|
979
|
+
ctx.cancel_timer("tick");
|
|
980
|
+
return Ok(());
|
|
981
|
+
}
|
|
982
|
+
let ended = h.game.phase != MatchPhase::Playing;
|
|
983
|
+
if ended {
|
|
984
|
+
ctx.cancel_timer("tick");
|
|
985
|
+
ctx.persist_now();
|
|
986
|
+
}
|
|
987
|
+
h.show_ball(ctx);
|
|
988
|
+
let due = ended || !h.game.events.is_empty() || h.game.ticks.is_multiple_of(PUBLISH_EVERY);
|
|
989
|
+
h.flush(ctx, due)
|
|
990
|
+
}
|
|
991
|
+
_ => Ok(()),
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
ckx_sdk::export_hub!(RacingNode);
|
|
997
|
+
|
|
998
|
+
#[cfg(test)]
|
|
999
|
+
mod tests {
|
|
1000
|
+
use super::*;
|
|
1001
|
+
use ckx_sdk::kit::error_code;
|
|
1002
|
+
use ckx_sdk::testing::TestHub;
|
|
1003
|
+
|
|
1004
|
+
fn code(e: &CallError) -> Option<&str> {
|
|
1005
|
+
error_code(&e.message).map(|c| c.0)
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
#[test]
|
|
1009
|
+
fn a_type_runs_as_its_seed_says_or_as_its_name() {
|
|
1010
|
+
assert_eq!(mode_of("race", b"").unwrap(), Mode::Race);
|
|
1011
|
+
assert_eq!(mode_of("ball", b"").unwrap(), Mode::Ball);
|
|
1012
|
+
assert_eq!(mode_of("kart", br#"{"mode":"race","countdown_ms":0}"#).unwrap(), Mode::Race);
|
|
1013
|
+
assert!(mode_of("kart", b"").is_err());
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/// Drives `player` around the default loop in steps the warden allows.
|
|
1017
|
+
fn lap(hub: &mut TestHub<RacingNode>, player: u64, path: &[(f64, f64)]) {
|
|
1018
|
+
for &(x, z) in path {
|
|
1019
|
+
hub.advance(1_000).unwrap();
|
|
1020
|
+
let _: (f64, f64) = hub.call_typed(Caller::Player(player), "pos", &json!({ "x": x, "z": z })).unwrap();
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const LOOP: [(f64, f64); 9] =
|
|
1025
|
+
[(10.0, 0.0), (20.0, 0.0), (30.0, 0.0), (25.0, 8.0), (20.0, 16.0), (15.0, 25.0), (10.0, 16.0), (5.0, 8.0), (0.0, 0.0)];
|
|
1026
|
+
|
|
1027
|
+
#[test]
|
|
1028
|
+
fn a_race_counts_down_times_gates_in_order_places_finishers_and_keeps_the_winners_ghost() {
|
|
1029
|
+
let mut hub = TestHub::<RacingNode>::spawn_with("race", "r1", br#"{"board":"fastest","finish_grace_ms":5000}"#).unwrap();
|
|
1030
|
+
let _: bool = hub.call_typed(Caller::Player(1), "join", &()).unwrap();
|
|
1031
|
+
let _: bool = hub.call_typed(Caller::Player(2), "join", &()).unwrap();
|
|
1032
|
+
let e = hub.call(Caller::Player(2), "start", &encode(&()).unwrap()).unwrap_err();
|
|
1033
|
+
assert_eq!(code(&e), Some(codes::FORBIDDEN));
|
|
1034
|
+
let _: bool = hub.call_typed(Caller::Player(1), "start", &()).unwrap();
|
|
1035
|
+
let e = hub.call(Caller::Player(3), "join", &encode(&()).unwrap()).unwrap_err();
|
|
1036
|
+
assert_eq!(code(&e), Some(codes::STARTED));
|
|
1037
|
+
hub.advance(3_000).unwrap();
|
|
1038
|
+
let view: RaceView = hub.call_typed(Caller::Player(1), "state", &()).unwrap();
|
|
1039
|
+
assert_eq!(view.phase, RacePhase::Racing);
|
|
1040
|
+
lap(&mut hub, 1, &LOOP);
|
|
1041
|
+
lap(&mut hub, 1, &LOOP);
|
|
1042
|
+
let view: RaceView = hub.call_typed(Caller::Player(1), "state", &()).unwrap();
|
|
1043
|
+
assert_eq!(view.racers[0].place, Some(1), "{view:?}");
|
|
1044
|
+
assert_eq!(view.racers[0].total_ms, Some(18_000));
|
|
1045
|
+
let ghost: Option<Ghost> = hub.call_typed(Caller::Player(2), "ghost", &()).unwrap();
|
|
1046
|
+
let ghost = ghost.expect("the winner's ghost");
|
|
1047
|
+
assert_eq!((ghost.player, ghost.frames.len()), (1, 19));
|
|
1048
|
+
// The other racer is given the grace, then did not finish.
|
|
1049
|
+
hub.advance(6_000).unwrap();
|
|
1050
|
+
let view: RaceView = hub.call_typed(Caller::Player(1), "state", &()).unwrap();
|
|
1051
|
+
assert_eq!(view.phase, RacePhase::Finished);
|
|
1052
|
+
assert!(view.racers[1].did_not_finish);
|
|
1053
|
+
let sent = hub.sent();
|
|
1054
|
+
assert_eq!(sent.len(), 1, "only a finisher's time goes to the board");
|
|
1055
|
+
assert_eq!((sent[0].node_type.as_str(), sent[0].key.as_str()), ("leaderboard", "fastest"));
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
#[test]
|
|
1059
|
+
fn the_warden_keeps_a_racer_from_cutting_the_course() {
|
|
1060
|
+
let mut race = Race::default();
|
|
1061
|
+
race.join(1, 0).unwrap();
|
|
1062
|
+
race.start(0).unwrap();
|
|
1063
|
+
race.go(3_000);
|
|
1064
|
+
let kept = race.moved(1, (30.0, 0.0), 3_100).unwrap();
|
|
1065
|
+
assert_eq!(kept, (0.0, 0.0), "a jump across the course is refused");
|
|
1066
|
+
assert_eq!(race.racers[0].timer.next_gate, 1, "and passes no gate");
|
|
1067
|
+
assert!(race.events.iter().any(|e| e["type"] == "violation"));
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
fn pitch() -> Match {
|
|
1071
|
+
let mut m = Match::default();
|
|
1072
|
+
m.join(1, "red", 0).unwrap();
|
|
1073
|
+
m.join(2, "red", 0).unwrap();
|
|
1074
|
+
m.join(3, "blue", 0).unwrap();
|
|
1075
|
+
m.start(0).unwrap();
|
|
1076
|
+
m
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
#[test]
|
|
1080
|
+
fn possession_is_claimed_near_the_ball_protected_for_a_moment_and_passed_to_a_teammate() {
|
|
1081
|
+
let mut m = pitch();
|
|
1082
|
+
let e = m.claim(1, 100).unwrap_err();
|
|
1083
|
+
assert_eq!(error_code(&e.0).map(|c| c.0), Some(codes::OUT_OF_REACH));
|
|
1084
|
+
m.players[0].warden.place((30.0, 20.0), 100);
|
|
1085
|
+
m.claim(1, 200).unwrap();
|
|
1086
|
+
assert_eq!(m.ball.holder, Some(1));
|
|
1087
|
+
m.players[2].warden.place((30.5, 20.0), 300);
|
|
1088
|
+
let e = m.claim(3, 400).unwrap_err();
|
|
1089
|
+
assert_eq!(error_code(&e.0).map(|c| c.0), Some(codes::COOLDOWN), "protected");
|
|
1090
|
+
m.claim(3, 2_000).unwrap();
|
|
1091
|
+
assert_eq!(m.ball.holder, Some(3), "taken after the protection");
|
|
1092
|
+
let e = m.pass(3, 1).unwrap_err();
|
|
1093
|
+
assert_eq!(error_code(&e.0).map(|c| c.0), Some(codes::BAD_REQUEST), "not to an opponent");
|
|
1094
|
+
m.players[2].warden.place((30.5, 20.0), 2_000);
|
|
1095
|
+
m.claim(1, 4_000).unwrap();
|
|
1096
|
+
m.pass(1, 2).unwrap();
|
|
1097
|
+
assert!(m.ball.holder.is_none() && m.ball.vx < 0.0, "toward red's other player at (15, 20)");
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
#[test]
|
|
1101
|
+
fn a_shot_into_the_goal_scores_and_the_first_to_the_target_wins() {
|
|
1102
|
+
let mut m = pitch();
|
|
1103
|
+
m.config.score_to_win = 1;
|
|
1104
|
+
m.players[0].warden.place((30.0, 20.0), 0);
|
|
1105
|
+
m.claim(1, 100).unwrap();
|
|
1106
|
+
m.shoot(1, (60.0, 20.0)).unwrap();
|
|
1107
|
+
let mut now = 100;
|
|
1108
|
+
while m.phase == MatchPhase::Playing && now < 10_000 {
|
|
1109
|
+
now += TICK_MS;
|
|
1110
|
+
m.tick(now);
|
|
1111
|
+
}
|
|
1112
|
+
assert_eq!((m.score, m.winner), ([1, 0], Some(0)));
|
|
1113
|
+
assert!(m.events.iter().any(|e| e["type"] == "goal"));
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
#[test]
|
|
1117
|
+
fn a_wide_shot_comes_back_off_the_end_wall() {
|
|
1118
|
+
let mut m = pitch();
|
|
1119
|
+
m.players[0].warden.place((55.0, 2.0), 0);
|
|
1120
|
+
m.claim(1, 0).unwrap_err();
|
|
1121
|
+
m.ball = BallState { x: 55.0, z: 2.0, ..BallState::default() };
|
|
1122
|
+
m.claim(1, 600).unwrap();
|
|
1123
|
+
m.shoot(1, (70.0, 2.0)).unwrap();
|
|
1124
|
+
for i in 1..10 {
|
|
1125
|
+
m.tick(600 + i * TICK_MS);
|
|
1126
|
+
}
|
|
1127
|
+
assert_eq!(m.score, [0, 0]);
|
|
1128
|
+
assert!(m.ball.vx < 0.0, "it bounced: {:?}", m.ball);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
#[test]
|
|
1132
|
+
fn an_anchored_pitch_shows_its_ball_and_sends_possession_changes() {
|
|
1133
|
+
let mut hub = TestHub::<RacingNode>::spawn_with("ball", "m1", br#"{"anchor":[10,0,10]}"#).unwrap();
|
|
1134
|
+
let _: bool = hub.call_typed(Caller::Player(1), "join", &json!({ "team": "red" })).unwrap();
|
|
1135
|
+
let _: bool = hub.call_typed(Caller::Player(2), "join", &json!({ "team": "blue" })).unwrap();
|
|
1136
|
+
let e = hub.call(Caller::Player(1), "join", &encode(&json!({ "team": "green" })).unwrap()).unwrap_err();
|
|
1137
|
+
assert_eq!(code(&e), Some(codes::BAD_REQUEST));
|
|
1138
|
+
let _: bool = hub.call_typed(Caller::Player(1), "start", &()).unwrap();
|
|
1139
|
+
let _: (f64, f64) = hub.call_typed(Caller::Player(1), "pos", &json!({ "x": 15.0, "z": 20.0 })).unwrap();
|
|
1140
|
+
hub.advance(2_000).unwrap();
|
|
1141
|
+
for x in [20.0, 25.0, 29.5] {
|
|
1142
|
+
let _: (f64, f64) = hub.call_typed(Caller::Player(1), "pos", &json!({ "x": x, "z": 20.0 })).unwrap();
|
|
1143
|
+
hub.advance(1_000).unwrap();
|
|
1144
|
+
}
|
|
1145
|
+
let _: bool = hub.call_typed(Caller::Player(1), "claim", &()).unwrap();
|
|
1146
|
+
let emitted = hub.emitted().len();
|
|
1147
|
+
assert!(emitted >= 20, "the ball is shown each tick: {emitted}");
|
|
1148
|
+
let view: BallView = hub.call_typed(Caller::Player(2), "state", &()).unwrap();
|
|
1149
|
+
assert_eq!(view.ball.holder, Some(1));
|
|
1150
|
+
}
|
|
1151
|
+
}
|