@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,849 @@
|
|
|
1
|
+
//! An arena, keyed by its id: players fight in real time with the abilities of the class they
|
|
2
|
+
//! pick, and the hub referees every hit. The shape of a brawler, an arena shooter, a skirmish:
|
|
3
|
+
//! clients report where their player is (`move { x, z }`, a few times a second) and the movement
|
|
4
|
+
//! warden (`ckx_sdk::kit::play::movement`) keeps what the class's speed allows; a cast is checked
|
|
5
|
+
//! for its cooldown, resource and range (`ckx_sdk::kit::play::abilities`) and lands as the hub
|
|
6
|
+
//! decides: a hit-scan strike on the fighter nearest its aim, a projectile the hub flies a tick
|
|
7
|
+
//! at a time, an area that goes off after its delay; statuses tick (`ckx_sdk::kit::play::combat`);
|
|
8
|
+
//! the fallen come back after `respawn_ms`; the first to `kill_target` kills, or the most kills
|
|
9
|
+
//! when the time runs out, wins. Replace the [`Config`]'s classes with your game's.
|
|
10
|
+
//!
|
|
11
|
+
//! Positions are the arena's own units, `(0, 0)` to `(width, depth)`. With an `anchor` (the
|
|
12
|
+
//! chunk the arena is at in the world), casts, impacts and the warden's flags also go out as
|
|
13
|
+
//! realtime events (94 and 95) to the players around it.
|
|
14
|
+
//!
|
|
15
|
+
//! Endpoints (players): `join { class }`, `start` (the first to join, or a developer), `move
|
|
16
|
+
//! { x, z }` (answers the position kept), `cast { ability, x, z }` (aimed at a point), `leave`,
|
|
17
|
+
//! `state` (anyone). It speaks the kit protocol (`ckx_sdk::kit`): the `arena` topic carries the
|
|
18
|
+
//! fighters (health, resource, statuses, kills, positions), projectiles and pending areas as
|
|
19
|
+
//! `snapshot` messages up to five times a second, and `event` messages (`type`: `cast`,
|
|
20
|
+
//! `impact`, `kill`, `respawn`, `violation`, `removed`, `finished`) as they happen;
|
|
21
|
+
//! `snapshot { topic: "arena" }` answers it; changes may carry an `action_id`; refusals carry
|
|
22
|
+
//! `kit:<code>:` codes.
|
|
23
|
+
|
|
24
|
+
use ckx_sdk::kit::play::abilities::{AbilityKind, AbilitySpec, CasterBook, PendingAoe, Projectile, ProjectileStep, validate_cast};
|
|
25
|
+
use ckx_sdk::kit::play::combat::{Combatant, RespawnBook};
|
|
26
|
+
use ckx_sdk::kit::play::movement::{Rules, Warden};
|
|
27
|
+
use ckx_sdk::kit::{self, Publisher, Receipts, codes, wire};
|
|
28
|
+
use ckx_sdk::prelude::*;
|
|
29
|
+
use serde_json::{Value, json};
|
|
30
|
+
|
|
31
|
+
pub const TOPIC: &str = "arena";
|
|
32
|
+
pub const TICK_MS: u64 = 100;
|
|
33
|
+
/// Ticks between snapshots while nothing else publishes.
|
|
34
|
+
const PUBLISH_EVERY: u64 = 2;
|
|
35
|
+
/// How near its aim a hit-scan strike finds its victim.
|
|
36
|
+
const AIM_RADIUS: f64 = 1.5;
|
|
37
|
+
const EVENT_DISTANCE: u8 = 3;
|
|
38
|
+
|
|
39
|
+
/// A status an ability leaves on its victim.
|
|
40
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
41
|
+
pub struct Affliction {
|
|
42
|
+
pub kind: String,
|
|
43
|
+
pub per_tick: i64,
|
|
44
|
+
pub every_ms: u64,
|
|
45
|
+
pub duration_ms: u64,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
49
|
+
pub struct Ability {
|
|
50
|
+
#[serde(flatten)]
|
|
51
|
+
pub spec: AbilitySpec,
|
|
52
|
+
#[serde(default)]
|
|
53
|
+
pub status: Option<Affliction>,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
57
|
+
pub struct Class {
|
|
58
|
+
pub id: String,
|
|
59
|
+
pub max_hp: i64,
|
|
60
|
+
/// Units a second.
|
|
61
|
+
pub speed: f64,
|
|
62
|
+
pub abilities: Vec<Ability>,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
66
|
+
#[serde(default)]
|
|
67
|
+
pub struct Config {
|
|
68
|
+
pub width: f64,
|
|
69
|
+
pub depth: f64,
|
|
70
|
+
pub spawns: Vec<(f64, f64)>,
|
|
71
|
+
pub classes: Vec<Class>,
|
|
72
|
+
pub players_max: usize,
|
|
73
|
+
pub kill_target: u32,
|
|
74
|
+
pub duration_ms: u64,
|
|
75
|
+
pub respawn_ms: u64,
|
|
76
|
+
/// A reported move this far at once is refused.
|
|
77
|
+
pub teleport: f64,
|
|
78
|
+
/// The warden's strikes (in its window) that remove a player; 0 never does.
|
|
79
|
+
pub remove_after_strikes: u32,
|
|
80
|
+
/// The chunk the arena is at, for realtime events.
|
|
81
|
+
pub anchor: Option<(i64, i64, i64)>,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fn ability(id: &str, cooldown_ms: u64, cost: i64, range: f32, kind: AbilityKind, status: Option<Affliction>) -> Ability {
|
|
85
|
+
Ability { spec: AbilitySpec { ability_id: id.into(), cooldown_ms, resource_cost: cost, range, kind }, status }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
impl Default for Config {
|
|
89
|
+
fn default() -> Self {
|
|
90
|
+
let warrior = Class {
|
|
91
|
+
id: "warrior".into(),
|
|
92
|
+
max_hp: 150,
|
|
93
|
+
speed: 6.0,
|
|
94
|
+
abilities: vec![
|
|
95
|
+
ability("strike", 600, 0, 2.5, AbilityKind::Instant { damage: 18 }, None),
|
|
96
|
+
ability("slam", 4_000, 30, 4.0, AbilityKind::Aoe { radius: 3.0, damage: 25, delay_ms: 300, falloff: true }, None),
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
let mage = Class {
|
|
100
|
+
id: "mage".into(),
|
|
101
|
+
max_hp: 100,
|
|
102
|
+
speed: 6.5,
|
|
103
|
+
abilities: vec![
|
|
104
|
+
ability("bolt", 800, 10, 18.0, AbilityKind::Projectile { speed: 20.0, damage: 22, max_range: 18.0, hit_radius: 0.8 }, None),
|
|
105
|
+
ability(
|
|
106
|
+
"ignite",
|
|
107
|
+
5_000,
|
|
108
|
+
25,
|
|
109
|
+
10.0,
|
|
110
|
+
AbilityKind::Instant { damage: 6 },
|
|
111
|
+
Some(Affliction { kind: "burning".into(), per_tick: 4, every_ms: 500, duration_ms: 3_000 }),
|
|
112
|
+
),
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
Self {
|
|
116
|
+
width: 40.0,
|
|
117
|
+
depth: 40.0,
|
|
118
|
+
spawns: vec![(5.0, 5.0), (35.0, 35.0), (5.0, 35.0), (35.0, 5.0)],
|
|
119
|
+
classes: vec![warrior, mage],
|
|
120
|
+
players_max: 10,
|
|
121
|
+
kill_target: 10,
|
|
122
|
+
duration_ms: 5 * 60_000,
|
|
123
|
+
respawn_ms: 3_000,
|
|
124
|
+
teleport: 12.0,
|
|
125
|
+
remove_after_strikes: 8,
|
|
126
|
+
anchor: None,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
132
|
+
#[serde(rename_all = "snake_case")]
|
|
133
|
+
pub enum Phase {
|
|
134
|
+
#[default]
|
|
135
|
+
Waiting,
|
|
136
|
+
Running,
|
|
137
|
+
Finished,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
141
|
+
pub struct Fighter {
|
|
142
|
+
pub player: u64,
|
|
143
|
+
pub class: String,
|
|
144
|
+
pub combatant: Combatant,
|
|
145
|
+
pub caster: CasterBook,
|
|
146
|
+
pub warden: Warden,
|
|
147
|
+
pub spawn: (f64, f64),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
impl Fighter {
|
|
151
|
+
pub fn at(&self) -> (f64, f64) {
|
|
152
|
+
self.warden.position().unwrap_or(self.spawn)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
157
|
+
pub struct FighterView {
|
|
158
|
+
pub player: u64,
|
|
159
|
+
pub class: String,
|
|
160
|
+
pub x: f64,
|
|
161
|
+
pub z: f64,
|
|
162
|
+
pub hp: i64,
|
|
163
|
+
pub max_hp: i64,
|
|
164
|
+
pub resource: i64,
|
|
165
|
+
pub statuses: Vec<String>,
|
|
166
|
+
pub kills: u32,
|
|
167
|
+
pub deaths: u32,
|
|
168
|
+
pub down: bool,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
172
|
+
pub struct Shot {
|
|
173
|
+
pub ability: String,
|
|
174
|
+
pub caster: u64,
|
|
175
|
+
pub x: f64,
|
|
176
|
+
pub z: f64,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
180
|
+
pub struct Blast {
|
|
181
|
+
pub ability: String,
|
|
182
|
+
pub caster: u64,
|
|
183
|
+
pub x: f64,
|
|
184
|
+
pub z: f64,
|
|
185
|
+
pub radius: f64,
|
|
186
|
+
pub at: u64,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/// The arena as everyone sees it.
|
|
190
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
191
|
+
pub struct ArenaView {
|
|
192
|
+
pub phase: Phase,
|
|
193
|
+
pub width: f64,
|
|
194
|
+
pub depth: f64,
|
|
195
|
+
pub fighters: Vec<FighterView>,
|
|
196
|
+
pub projectiles: Vec<Shot>,
|
|
197
|
+
pub areas: Vec<Blast>,
|
|
198
|
+
pub kill_target: u32,
|
|
199
|
+
pub ends_at: Option<u64>,
|
|
200
|
+
pub winner: Option<u64>,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#[derive(Deserialize)]
|
|
204
|
+
struct Join {
|
|
205
|
+
class: String,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#[derive(Deserialize)]
|
|
209
|
+
struct Move {
|
|
210
|
+
x: f64,
|
|
211
|
+
z: f64,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#[derive(Deserialize)]
|
|
215
|
+
struct Cast {
|
|
216
|
+
ability: String,
|
|
217
|
+
x: f64,
|
|
218
|
+
z: f64,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
222
|
+
pub struct Arena {
|
|
223
|
+
pub config: Config,
|
|
224
|
+
pub phase: Phase,
|
|
225
|
+
pub fighters: Vec<Fighter>,
|
|
226
|
+
pub projectiles: Vec<Projectile>,
|
|
227
|
+
pub areas: Vec<PendingAoe>,
|
|
228
|
+
pub respawns: RespawnBook,
|
|
229
|
+
pub ends_at: Option<u64>,
|
|
230
|
+
pub winner: Option<u64>,
|
|
231
|
+
pub ticks: u64,
|
|
232
|
+
/// Events since they were last published.
|
|
233
|
+
#[serde(skip)]
|
|
234
|
+
pub events: Vec<Value>,
|
|
235
|
+
/// Whether the view changed since it was last published.
|
|
236
|
+
#[serde(skip)]
|
|
237
|
+
pub dirty: bool,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {
|
|
241
|
+
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
impl Arena {
|
|
245
|
+
pub fn view(&self) -> ArenaView {
|
|
246
|
+
ArenaView {
|
|
247
|
+
phase: self.phase,
|
|
248
|
+
width: self.config.width,
|
|
249
|
+
depth: self.config.depth,
|
|
250
|
+
fighters: self
|
|
251
|
+
.fighters
|
|
252
|
+
.iter()
|
|
253
|
+
.map(|f| {
|
|
254
|
+
let (x, z) = f.at();
|
|
255
|
+
FighterView {
|
|
256
|
+
player: f.player,
|
|
257
|
+
class: f.class.clone(),
|
|
258
|
+
x,
|
|
259
|
+
z,
|
|
260
|
+
hp: f.combatant.health.current,
|
|
261
|
+
max_hp: f.combatant.health.max,
|
|
262
|
+
resource: f.caster.resource,
|
|
263
|
+
statuses: f.combatant.statuses.iter().map(|s| s.kind.clone()).collect(),
|
|
264
|
+
kills: f.combatant.kills,
|
|
265
|
+
deaths: f.combatant.deaths,
|
|
266
|
+
down: self.respawns.is_down(&f.player.to_string()),
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
.collect(),
|
|
270
|
+
projectiles: self
|
|
271
|
+
.projectiles
|
|
272
|
+
.iter()
|
|
273
|
+
.map(|p| Shot {
|
|
274
|
+
ability: p.ability_id.clone(),
|
|
275
|
+
caster: p.caster_id.parse().unwrap_or(0),
|
|
276
|
+
x: f64::from(p.x),
|
|
277
|
+
z: f64::from(p.z),
|
|
278
|
+
})
|
|
279
|
+
.collect(),
|
|
280
|
+
areas: self
|
|
281
|
+
.areas
|
|
282
|
+
.iter()
|
|
283
|
+
.map(|a| Blast {
|
|
284
|
+
ability: a.ability_id.clone(),
|
|
285
|
+
caster: a.caster_id.parse().unwrap_or(0),
|
|
286
|
+
x: f64::from(a.x),
|
|
287
|
+
z: f64::from(a.z),
|
|
288
|
+
radius: f64::from(a.radius),
|
|
289
|
+
at: a.detonate_at_ms,
|
|
290
|
+
})
|
|
291
|
+
.collect(),
|
|
292
|
+
kill_target: self.config.kill_target,
|
|
293
|
+
ends_at: self.ends_at,
|
|
294
|
+
winner: self.winner,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
fn event(&mut self, kind: &str, extra: Value) {
|
|
299
|
+
let mut e = json!({ "type": kind });
|
|
300
|
+
if let (Some(e), Some(x)) = (e.as_object_mut(), extra.as_object()) {
|
|
301
|
+
e.extend(x.clone());
|
|
302
|
+
}
|
|
303
|
+
self.events.push(e);
|
|
304
|
+
self.dirty = true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
fn index(&self, player: u64) -> Result<usize> {
|
|
308
|
+
self.fighters.iter().position(|f| f.player == player).ok_or_else(|| kit::error(codes::NOT_MEMBER, "join the arena first"))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
fn rules(&self, class: &str) -> Rules {
|
|
312
|
+
let speed = self.config.classes.iter().find(|c| c.id == class).map_or(6.0, |c| c.speed);
|
|
313
|
+
Rules {
|
|
314
|
+
max_speed: speed,
|
|
315
|
+
teleport: self.config.teleport,
|
|
316
|
+
bounds: Some((0.0, 0.0, self.config.width, self.config.depth)),
|
|
317
|
+
grace: 1.25,
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
pub fn join(&mut self, player: u64, class: &str, now: u64) -> Result<()> {
|
|
322
|
+
let Some(c) = self.config.classes.iter().find(|c| c.id == class).cloned() else {
|
|
323
|
+
let names: Vec<&str> = self.config.classes.iter().map(|c| c.id.as_str()).collect();
|
|
324
|
+
return Err(kit::error(codes::BAD_REQUEST, format!("a class is one of {}", names.join(", "))));
|
|
325
|
+
};
|
|
326
|
+
if let Ok(i) = self.index(player) {
|
|
327
|
+
if self.phase == Phase::Running {
|
|
328
|
+
return Err(kit::error(codes::STARTED, "classes are picked before the fight"));
|
|
329
|
+
}
|
|
330
|
+
self.fighters[i].class = c.id;
|
|
331
|
+
self.fighters[i].combatant = Combatant::new(c.max_hp);
|
|
332
|
+
self.dirty = true;
|
|
333
|
+
return Ok(());
|
|
334
|
+
}
|
|
335
|
+
if self.fighters.len() >= self.config.players_max {
|
|
336
|
+
return Err(kit::error(codes::FULL, "the arena is full"));
|
|
337
|
+
}
|
|
338
|
+
let spawn = self.config.spawns.get(self.fighters.len() % self.config.spawns.len().max(1)).copied().unwrap_or((0.0, 0.0));
|
|
339
|
+
self.fighters.push(Fighter {
|
|
340
|
+
player,
|
|
341
|
+
class: c.id,
|
|
342
|
+
combatant: Combatant::new(c.max_hp),
|
|
343
|
+
caster: CasterBook::default(),
|
|
344
|
+
warden: Warden::at(spawn, now),
|
|
345
|
+
spawn,
|
|
346
|
+
});
|
|
347
|
+
self.dirty = true;
|
|
348
|
+
Ok(())
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
pub fn leave(&mut self, player: u64) -> Result<()> {
|
|
352
|
+
let i = self.index(player)?;
|
|
353
|
+
self.fighters.remove(i);
|
|
354
|
+
self.respawns.kills.remove(&player.to_string());
|
|
355
|
+
self.dirty = true;
|
|
356
|
+
Ok(())
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
pub fn start(&mut self, now: u64) -> Result<()> {
|
|
360
|
+
if self.phase == Phase::Running {
|
|
361
|
+
return Err(kit::error(codes::STARTED, "the fight is on"));
|
|
362
|
+
}
|
|
363
|
+
if self.fighters.len() < 2 {
|
|
364
|
+
return Err(kit::error(codes::NOT_READY, "a fight takes two"));
|
|
365
|
+
}
|
|
366
|
+
self.phase = Phase::Running;
|
|
367
|
+
self.ends_at = Some(now + self.config.duration_ms);
|
|
368
|
+
self.winner = None;
|
|
369
|
+
self.projectiles.clear();
|
|
370
|
+
self.areas.clear();
|
|
371
|
+
self.respawns = RespawnBook::default();
|
|
372
|
+
let classes = self.config.classes.clone();
|
|
373
|
+
for f in &mut self.fighters {
|
|
374
|
+
let max = classes.iter().find(|c| c.id == f.class).map_or(100, |c| c.max_hp);
|
|
375
|
+
f.combatant = Combatant::new(max);
|
|
376
|
+
f.caster = CasterBook::default();
|
|
377
|
+
f.warden = Warden::at(f.spawn, now);
|
|
378
|
+
}
|
|
379
|
+
self.dirty = true;
|
|
380
|
+
Ok(())
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
fn standing(&self, i: usize) -> Result<()> {
|
|
384
|
+
if self.phase != Phase::Running {
|
|
385
|
+
return Err(kit::error(codes::NOT_READY, "the fight has not started"));
|
|
386
|
+
}
|
|
387
|
+
if self.respawns.is_down(&self.fighters[i].player.to_string()) {
|
|
388
|
+
return Err(kit::error(codes::CONFLICT, "you are down"));
|
|
389
|
+
}
|
|
390
|
+
Ok(())
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/// A reported position: the warden keeps what the class allows. Returns the position kept.
|
|
394
|
+
pub fn moved(&mut self, player: u64, to: (f64, f64), now: u64) -> Result<(f64, f64)> {
|
|
395
|
+
let i = self.index(player)?;
|
|
396
|
+
self.standing(i)?;
|
|
397
|
+
let rules = self.rules(&self.fighters[i].class);
|
|
398
|
+
let verdict = self.fighters[i].warden.check(&rules, to, now);
|
|
399
|
+
if let Some(v) = verdict.violation {
|
|
400
|
+
self.event("violation", json!({ "player": player, "what": v.kind.as_str(), "detail": v.detail }));
|
|
401
|
+
let limit = self.config.remove_after_strikes;
|
|
402
|
+
if limit > 0 && self.fighters[i].warden.strikes(now) >= limit {
|
|
403
|
+
self.fighters.remove(i);
|
|
404
|
+
self.respawns.kills.remove(&player.to_string());
|
|
405
|
+
self.event("removed", json!({ "player": player, "why": "movement" }));
|
|
406
|
+
return Err(kit::error(codes::FORBIDDEN, "removed for movement the warden refused"));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
self.dirty = true;
|
|
410
|
+
Ok(verdict.at)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/// A cast aimed at `aim`. Returns whether it hit someone at once (a hit-scan strike).
|
|
414
|
+
pub fn cast(&mut self, player: u64, ability_id: &str, aim: (f64, f64), now: u64) -> Result<bool> {
|
|
415
|
+
let i = self.index(player)?;
|
|
416
|
+
self.standing(i)?;
|
|
417
|
+
let class = self.fighters[i].class.clone();
|
|
418
|
+
let Some(ability) = self
|
|
419
|
+
.config
|
|
420
|
+
.classes
|
|
421
|
+
.iter()
|
|
422
|
+
.find(|c| c.id == class)
|
|
423
|
+
.and_then(|c| c.abilities.iter().find(|a| a.spec.ability_id == ability_id))
|
|
424
|
+
.cloned()
|
|
425
|
+
else {
|
|
426
|
+
return Err(kit::error(codes::NOT_FOUND, format!("a {class} has no ability `{ability_id}`")));
|
|
427
|
+
};
|
|
428
|
+
let from = self.fighters[i].at();
|
|
429
|
+
let to_f32 = |p: (f64, f64)| (p.0 as f32, p.1 as f32);
|
|
430
|
+
let cast = validate_cast(&ability.spec, &mut self.fighters[i].caster, to_f32(from), to_f32(aim), now).map_err(|why| match why {
|
|
431
|
+
"ability on cooldown" => kit::error(codes::COOLDOWN, why),
|
|
432
|
+
"not enough resource" => kit::error(codes::INSUFFICIENT, why),
|
|
433
|
+
_ => kit::error(codes::OUT_OF_REACH, why),
|
|
434
|
+
})?;
|
|
435
|
+
self.event("cast", json!({ "ability": ability_id, "caster": player, "x": aim.0, "z": aim.1 }));
|
|
436
|
+
match ability.spec.kind {
|
|
437
|
+
AbilityKind::Instant { damage } => {
|
|
438
|
+
let victim = self
|
|
439
|
+
.fighters
|
|
440
|
+
.iter()
|
|
441
|
+
.enumerate()
|
|
442
|
+
.filter(|(j, f)| *j != i && !self.respawns.is_down(&f.player.to_string()) && dist(f.at(), aim) <= AIM_RADIUS)
|
|
443
|
+
.min_by(|a, b| dist(a.1.at(), aim).total_cmp(&dist(b.1.at(), aim)))
|
|
444
|
+
.map(|(j, _)| j);
|
|
445
|
+
if let Some(j) = victim {
|
|
446
|
+
self.damage(player, j, ability_id, damage, ability.status.as_ref(), now);
|
|
447
|
+
return Ok(true);
|
|
448
|
+
}
|
|
449
|
+
Ok(false)
|
|
450
|
+
}
|
|
451
|
+
AbilityKind::Projectile { .. } => {
|
|
452
|
+
self.projectiles.extend(Projectile::launch(&ability.spec, &player.to_string(), to_f32(from), &cast, now));
|
|
453
|
+
Ok(false)
|
|
454
|
+
}
|
|
455
|
+
AbilityKind::Aoe { .. } => {
|
|
456
|
+
self.areas.extend(PendingAoe::from_cast(&ability.spec, &player.to_string(), &cast, now));
|
|
457
|
+
Ok(false)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
fn damage(&mut self, caster: u64, victim: usize, ability_id: &str, amount: i64, status: Option<&Affliction>, now: u64) {
|
|
463
|
+
let f = &mut self.fighters[victim];
|
|
464
|
+
let target = f.player;
|
|
465
|
+
let outcome = f.combatant.hit(amount);
|
|
466
|
+
if let Some(s) = status.filter(|_| !outcome.killed) {
|
|
467
|
+
f.combatant.afflict(&s.kind, s.per_tick, s.every_ms, s.duration_ms, Some(caster.to_string()), now);
|
|
468
|
+
}
|
|
469
|
+
self.event("impact", json!({ "ability": ability_id, "caster": caster, "victim": target, "damage": outcome.dealt }));
|
|
470
|
+
if outcome.killed {
|
|
471
|
+
self.killed(caster, victim, now);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
fn killed(&mut self, killer: u64, victim: usize, now: u64) {
|
|
476
|
+
let target = self.fighters[victim].player;
|
|
477
|
+
if killer != target
|
|
478
|
+
&& let Some(k) = self.fighters.iter_mut().find(|f| f.player == killer)
|
|
479
|
+
{
|
|
480
|
+
k.combatant.kills += 1;
|
|
481
|
+
}
|
|
482
|
+
self.respawns.record_kill(&target.to_string(), &killer.to_string(), now, self.config.respawn_ms);
|
|
483
|
+
self.event("kill", json!({ "killer": killer, "victim": target }));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/// One tick: projectiles, areas, statuses, respawns, the end. Returns whether it ran.
|
|
487
|
+
pub fn tick(&mut self, now: u64) -> bool {
|
|
488
|
+
if self.phase != Phase::Running {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
self.ticks += 1;
|
|
492
|
+
let mut shots = std::mem::take(&mut self.projectiles);
|
|
493
|
+
let positions: Vec<(String, (f64, f64), bool)> =
|
|
494
|
+
self.fighters.iter().map(|f| (f.player.to_string(), f.at(), self.respawns.is_down(&f.player.to_string()))).collect();
|
|
495
|
+
let mut hits = Vec::new();
|
|
496
|
+
shots.retain_mut(|p| {
|
|
497
|
+
let caster = p.caster_id.clone();
|
|
498
|
+
let mut hit_test = |x: f32, z: f32, r: f32| {
|
|
499
|
+
positions
|
|
500
|
+
.iter()
|
|
501
|
+
.filter(|(id, _, down)| *id != caster && !down)
|
|
502
|
+
.find(|(_, at, _)| dist(*at, (f64::from(x), f64::from(z))) <= f64::from(r))
|
|
503
|
+
.map(|(id, _, _)| id.clone())
|
|
504
|
+
};
|
|
505
|
+
let (w, d) = (self.config.width as f32, self.config.depth as f32);
|
|
506
|
+
let mut blocked = |x: f32, z: f32| x < 0.0 || z < 0.0 || x > w || z > d;
|
|
507
|
+
match p.step(TICK_MS, &mut hit_test, &mut blocked) {
|
|
508
|
+
ProjectileStep::Flying => true,
|
|
509
|
+
ProjectileStep::Hit { target_id } => {
|
|
510
|
+
hits.push((p.caster_id.clone(), target_id, p.ability_id.clone(), p.damage));
|
|
511
|
+
false
|
|
512
|
+
}
|
|
513
|
+
ProjectileStep::Expired => false,
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
self.projectiles = shots;
|
|
517
|
+
if !self.projectiles.is_empty() || !hits.is_empty() {
|
|
518
|
+
self.dirty = true;
|
|
519
|
+
}
|
|
520
|
+
for (caster, target, ability_id, damage) in hits {
|
|
521
|
+
if let (Ok(caster), Some(j)) = (caster.parse::<u64>(), self.fighters.iter().position(|f| f.player.to_string() == target)) {
|
|
522
|
+
let status = self.status_of(&ability_id);
|
|
523
|
+
self.damage(caster, j, &ability_id, damage, status.as_ref(), now);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
let (ready, waiting): (Vec<PendingAoe>, Vec<PendingAoe>) = std::mem::take(&mut self.areas).into_iter().partition(|a| a.ready(now));
|
|
527
|
+
self.areas = waiting;
|
|
528
|
+
for blast in ready {
|
|
529
|
+
let victims: Vec<(String, (f32, f32))> = self
|
|
530
|
+
.fighters
|
|
531
|
+
.iter()
|
|
532
|
+
.filter(|f| !self.respawns.is_down(&f.player.to_string()))
|
|
533
|
+
.map(|f| (f.player.to_string(), (f.at().0 as f32, f.at().1 as f32)))
|
|
534
|
+
.collect();
|
|
535
|
+
let caster: u64 = blast.caster_id.parse().unwrap_or(0);
|
|
536
|
+
let status = self.status_of(&blast.ability_id);
|
|
537
|
+
for (id, damage) in blast.resolve(&victims) {
|
|
538
|
+
if let Some(j) = self.fighters.iter().position(|f| f.player.to_string() == id) {
|
|
539
|
+
self.damage(caster, j, &blast.ability_id, damage, status.as_ref(), now);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
self.dirty = true;
|
|
543
|
+
}
|
|
544
|
+
for i in 0..self.fighters.len() {
|
|
545
|
+
for effect in self.fighters[i].combatant.tick(now) {
|
|
546
|
+
self.dirty = true;
|
|
547
|
+
if effect.outcome.killed {
|
|
548
|
+
let killer = effect.source.as_deref().and_then(|s| s.parse().ok()).unwrap_or(self.fighters[i].player);
|
|
549
|
+
self.killed(killer, i, now);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
for id in self.respawns.due(now) {
|
|
554
|
+
if let Some(f) = self.fighters.iter_mut().find(|f| f.player.to_string() == id) {
|
|
555
|
+
f.combatant.health.revive();
|
|
556
|
+
f.combatant.statuses.clear();
|
|
557
|
+
f.warden.place(f.spawn, now);
|
|
558
|
+
let player = f.player;
|
|
559
|
+
self.event("respawn", json!({ "player": player }));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
let leader = self.fighters.iter().max_by_key(|f| f.combatant.kills).map(|f| (f.player, f.combatant.kills));
|
|
563
|
+
let tied = leader.is_some_and(|(p, k)| self.fighters.iter().any(|f| f.player != p && f.combatant.kills == k));
|
|
564
|
+
let reached = leader.is_some_and(|(_, k)| k >= self.config.kill_target);
|
|
565
|
+
if reached || self.ends_at.is_some_and(|t| now >= t) {
|
|
566
|
+
self.phase = Phase::Finished;
|
|
567
|
+
self.winner = leader.filter(|_| reached || !tied).map(|(p, _)| p);
|
|
568
|
+
self.event("finished", json!({ "winner": self.winner }));
|
|
569
|
+
}
|
|
570
|
+
true
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
fn status_of(&self, ability_id: &str) -> Option<Affliction> {
|
|
574
|
+
self.config.classes.iter().flat_map(|c| &c.abilities).find(|a| a.spec.ability_id == ability_id).and_then(|a| a.status.clone())
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/// The hub: the arena, and what the kit protocol keeps beside it.
|
|
579
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
580
|
+
pub struct ArenaHub {
|
|
581
|
+
#[serde(flatten)]
|
|
582
|
+
pub arena: Arena,
|
|
583
|
+
#[serde(default)]
|
|
584
|
+
pub kit: Publisher,
|
|
585
|
+
#[serde(default)]
|
|
586
|
+
pub receipts: Receipts,
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
impl ArenaHub {
|
|
590
|
+
fn act(&mut self, ctx: &Ctx, call: &Call<'_>, now: u64) -> Result<Vec<u8>> {
|
|
591
|
+
let a = &mut self.arena;
|
|
592
|
+
let out = match call.method {
|
|
593
|
+
"join" => {
|
|
594
|
+
let j: Join = kit::args(call)?;
|
|
595
|
+
a.join(call.player()?, &j.class, now)?;
|
|
596
|
+
encode(&true)
|
|
597
|
+
}
|
|
598
|
+
"leave" => {
|
|
599
|
+
a.leave(call.player()?)?;
|
|
600
|
+
encode(&true)
|
|
601
|
+
}
|
|
602
|
+
"start" => {
|
|
603
|
+
let allowed = match call.caller {
|
|
604
|
+
Caller::Developer(_) => true,
|
|
605
|
+
Caller::Player(p) => a.fighters.first().map(|f| f.player) == Some(p),
|
|
606
|
+
_ => false,
|
|
607
|
+
};
|
|
608
|
+
if !allowed {
|
|
609
|
+
return Err(kit::error(codes::FORBIDDEN, "the first to join, or a developer, starts it"));
|
|
610
|
+
}
|
|
611
|
+
a.start(now)?;
|
|
612
|
+
ctx.timer_every("tick", TICK_MS)?;
|
|
613
|
+
encode(&true)
|
|
614
|
+
}
|
|
615
|
+
"move" => {
|
|
616
|
+
let m: Move = kit::args(call)?;
|
|
617
|
+
let kept = a.moved(call.player()?, (m.x, m.z), now);
|
|
618
|
+
self.flush(ctx, false)?;
|
|
619
|
+
return encode(&kept?);
|
|
620
|
+
}
|
|
621
|
+
"cast" => {
|
|
622
|
+
let c: Cast = kit::args(call)?;
|
|
623
|
+
encode(&a.cast(call.player()?, &c.ability, (c.x, c.z), now)?)
|
|
624
|
+
}
|
|
625
|
+
other => return Err(Error::unknown_method(other)),
|
|
626
|
+
};
|
|
627
|
+
self.flush(ctx, true)?;
|
|
628
|
+
out
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/// Publishes the events since the last time, and the arena when `snapshot` says so.
|
|
632
|
+
fn flush(&mut self, ctx: &Ctx, snapshot: bool) -> Result<()> {
|
|
633
|
+
let events = std::mem::take(&mut self.arena.events);
|
|
634
|
+
for e in &events {
|
|
635
|
+
self.kit.event(ctx, TOPIC, e)?;
|
|
636
|
+
self.realtime(ctx, e);
|
|
637
|
+
}
|
|
638
|
+
if snapshot {
|
|
639
|
+
self.arena.dirty = false;
|
|
640
|
+
self.kit.snapshot(ctx, TOPIC, &self.arena.view())?;
|
|
641
|
+
}
|
|
642
|
+
Ok(())
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/// Casts, impacts and the warden's flags as realtime events, when the arena is anchored.
|
|
646
|
+
fn realtime(&self, ctx: &Ctx, e: &Value) {
|
|
647
|
+
let Some((x, y, z)) = self.arena.config.anchor else { return };
|
|
648
|
+
let at = ChunkPos::new(x, y, z);
|
|
649
|
+
let actor = actor_id(&format!("arena:{}", ctx.key));
|
|
650
|
+
let id = |k: &str| e[k].as_u64().map(|v| v.to_string());
|
|
651
|
+
let sent = match e["type"].as_str() {
|
|
652
|
+
Some(kind @ ("cast" | "impact")) => {
|
|
653
|
+
let body = wire::AbilityUse {
|
|
654
|
+
kind: kind.into(),
|
|
655
|
+
ability_id: e["ability"].as_str().unwrap_or_default().into(),
|
|
656
|
+
caster_id: id("caster").unwrap_or_default(),
|
|
657
|
+
victim_id: id("victim"),
|
|
658
|
+
damage: e["damage"].as_i64().unwrap_or(0),
|
|
659
|
+
};
|
|
660
|
+
wire::emit_event(ctx, at, EVENT_DISTANCE, &actor, wire::ABILITY, &body)
|
|
661
|
+
}
|
|
662
|
+
Some("violation") => {
|
|
663
|
+
let body = wire::MovementViolation {
|
|
664
|
+
kind: e["what"].as_str().unwrap_or_default().into(),
|
|
665
|
+
user_id: id("player").unwrap_or_default(),
|
|
666
|
+
detail: e["detail"].as_str().unwrap_or_default().into(),
|
|
667
|
+
};
|
|
668
|
+
wire::emit_event(ctx, at, EVENT_DISTANCE, &actor, wire::MOVEMENT_VIOLATION, &body)
|
|
669
|
+
}
|
|
670
|
+
_ => Ok(()),
|
|
671
|
+
};
|
|
672
|
+
if let Err(Error(why)) = sent {
|
|
673
|
+
ctx.log(Level::Warn, &format!("a realtime event did not go out: {why}"));
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
impl Hub for ArenaHub {
|
|
679
|
+
fn spawn(_ctx: &Ctx, seed: &[u8]) -> Result<Self> {
|
|
680
|
+
let mut hub = Self::default();
|
|
681
|
+
if !seed.is_empty() {
|
|
682
|
+
hub.arena.config = serde_json::from_slice(seed).map_err(|e| Error::new(format!("seed is not a JSON config: {e}")))?;
|
|
683
|
+
}
|
|
684
|
+
Ok(hub)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
|
|
688
|
+
decode(snapshot)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
|
|
692
|
+
encode(self)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
696
|
+
let now = ctx.now_ms();
|
|
697
|
+
match call.method {
|
|
698
|
+
"state" => encode(&self.arena.view()),
|
|
699
|
+
kit::SNAPSHOT_METHOD => self.kit.snapshot_reply(ctx, TOPIC, &self.arena.view()),
|
|
700
|
+
_ => Receipts::run(self, |h| &mut h.receipts, &call, now, |h| h.act(ctx, &call, now)),
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
|
|
705
|
+
if timer != "tick" {
|
|
706
|
+
return Ok(());
|
|
707
|
+
}
|
|
708
|
+
let now = ctx.now_ms();
|
|
709
|
+
if !self.arena.tick(now) {
|
|
710
|
+
ctx.cancel_timer("tick");
|
|
711
|
+
return Ok(());
|
|
712
|
+
}
|
|
713
|
+
let ended = self.arena.phase != Phase::Running;
|
|
714
|
+
if ended {
|
|
715
|
+
ctx.cancel_timer("tick");
|
|
716
|
+
ctx.persist_now();
|
|
717
|
+
}
|
|
718
|
+
let due = self.arena.dirty && (ended || self.arena.ticks.is_multiple_of(PUBLISH_EVERY));
|
|
719
|
+
self.flush(ctx, due)
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
ckx_sdk::export_hub!(ArenaHub);
|
|
724
|
+
|
|
725
|
+
#[cfg(test)]
|
|
726
|
+
mod tests {
|
|
727
|
+
use super::*;
|
|
728
|
+
use ckx_sdk::kit::{Message, error_code};
|
|
729
|
+
use ckx_sdk::testing::TestHub;
|
|
730
|
+
|
|
731
|
+
fn code(e: &CallError) -> Option<&str> {
|
|
732
|
+
error_code(&e.message).map(|c| c.0)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
fn duel() -> Arena {
|
|
736
|
+
let mut a = Arena { config: Config { spawns: vec![(10.0, 10.0), (12.0, 10.0)], ..Config::default() }, ..Default::default() };
|
|
737
|
+
a.join(1, "warrior", 0).unwrap();
|
|
738
|
+
a.join(2, "mage", 0).unwrap();
|
|
739
|
+
a.start(0).unwrap();
|
|
740
|
+
a
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
#[test]
|
|
744
|
+
fn a_strike_lands_on_the_fighter_nearest_its_aim_within_range_and_cooldown() {
|
|
745
|
+
let mut a = duel();
|
|
746
|
+
assert!(a.cast(1, "strike", (12.0, 10.0), 1_000).unwrap());
|
|
747
|
+
assert_eq!(a.fighters[1].combatant.health.current, 100 - 18);
|
|
748
|
+
let Error(e) = a.cast(1, "strike", (12.0, 10.0), 1_100).unwrap_err();
|
|
749
|
+
assert_eq!(error_code(&e).map(|c| c.0), Some(codes::COOLDOWN));
|
|
750
|
+
let Error(e) = a.cast(1, "strike", (30.0, 10.0), 2_000).unwrap_err();
|
|
751
|
+
assert_eq!(error_code(&e).map(|c| c.0), Some(codes::OUT_OF_REACH));
|
|
752
|
+
assert!(!a.cast(1, "strike", (10.0, 12.0), 3_000).unwrap(), "a miss spends the strike");
|
|
753
|
+
let Error(e) = a.cast(1, "bolt", (12.0, 10.0), 4_000).unwrap_err();
|
|
754
|
+
assert_eq!(error_code(&e).map(|c| c.0), Some(codes::NOT_FOUND));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
#[test]
|
|
758
|
+
fn a_bolt_flies_and_lands_and_a_burn_ticks_until_it_kills_with_credit() {
|
|
759
|
+
let mut a = duel();
|
|
760
|
+
a.fighters[0].warden.place((20.0, 10.0), 0);
|
|
761
|
+
a.cast(2, "bolt", (20.0, 10.0), 1_000).unwrap();
|
|
762
|
+
assert_eq!(a.projectiles.len(), 1);
|
|
763
|
+
let mut now = 1_000;
|
|
764
|
+
while !a.projectiles.is_empty() && now < 3_000 {
|
|
765
|
+
now += TICK_MS;
|
|
766
|
+
a.tick(now);
|
|
767
|
+
}
|
|
768
|
+
assert_eq!(a.fighters[0].combatant.health.current, 150 - 22);
|
|
769
|
+
a.fighters[0].combatant.health.current = 10;
|
|
770
|
+
a.fighters[0].warden.place((14.0, 10.0), now);
|
|
771
|
+
assert!(a.cast(2, "ignite", (14.0, 10.0), now).unwrap());
|
|
772
|
+
assert!(a.fighters[0].combatant.has("burning"));
|
|
773
|
+
for _ in 0..40 {
|
|
774
|
+
now += TICK_MS;
|
|
775
|
+
a.tick(now);
|
|
776
|
+
}
|
|
777
|
+
assert_eq!(a.fighters[1].combatant.kills, 1, "the burn's source gets the kill");
|
|
778
|
+
assert_eq!(a.fighters[0].combatant.health.current, 150, "back after the respawn");
|
|
779
|
+
assert!(a.events.iter().any(|e| e["type"] == "respawn"));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
#[test]
|
|
783
|
+
fn a_slam_goes_off_after_its_delay_and_the_first_to_the_target_wins() {
|
|
784
|
+
let mut a = duel();
|
|
785
|
+
a.config.kill_target = 1;
|
|
786
|
+
a.fighters[1].combatant.health.current = 10;
|
|
787
|
+
a.cast(1, "slam", (12.0, 10.0), 1_000).unwrap();
|
|
788
|
+
a.tick(1_100);
|
|
789
|
+
assert_eq!(a.fighters[1].combatant.health.current, 10, "not yet");
|
|
790
|
+
a.tick(1_400);
|
|
791
|
+
assert_eq!(a.phase, Phase::Finished);
|
|
792
|
+
assert_eq!(a.winner, Some(1));
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
#[test]
|
|
796
|
+
fn the_warden_cuts_fast_moves_refuses_teleports_and_removes_a_cheat() {
|
|
797
|
+
let mut a = duel();
|
|
798
|
+
let kept = a.moved(1, (11.0, 10.0), 500).unwrap();
|
|
799
|
+
assert_eq!(kept, (11.0, 10.0));
|
|
800
|
+
let kept = a.moved(1, (30.0, 10.0), 600).unwrap();
|
|
801
|
+
assert_eq!(kept, (11.0, 10.0), "a teleport is refused");
|
|
802
|
+
let kept = a.moved(1, (18.0, 10.0), 1_100).unwrap();
|
|
803
|
+
assert!(kept.0 < 18.0 && kept.0 > 11.0, "a fast move is cut: {kept:?}");
|
|
804
|
+
a.config.remove_after_strikes = 3;
|
|
805
|
+
let Error(e) = a.moved(1, (40.0, 40.0), 1_200).unwrap_err();
|
|
806
|
+
assert_eq!(error_code(&e).map(|c| c.0), Some(codes::FORBIDDEN));
|
|
807
|
+
assert!(a.fighters.iter().all(|f| f.player != 1));
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
#[test]
|
|
811
|
+
fn the_topic_carries_snapshots_and_events_and_a_time_out_decides_by_kills() {
|
|
812
|
+
let mut hub = TestHub::<ArenaHub>::spawn_with("arena", "pit", br#"{"duration_ms":2000,"spawns":[[10,10],[11,10]]}"#).unwrap();
|
|
813
|
+
let _: bool = hub.call_typed(Caller::Player(1), "join", &json!({ "class": "warrior" })).unwrap();
|
|
814
|
+
let _: bool = hub.call_typed(Caller::Player(2), "join", &json!({ "class": "mage" })).unwrap();
|
|
815
|
+
let e = hub.call(Caller::Player(3), "join", &encode(&json!({ "class": "bard" })).unwrap()).unwrap_err();
|
|
816
|
+
assert_eq!(code(&e), Some(codes::BAD_REQUEST));
|
|
817
|
+
let e = hub.call(Caller::Player(2), "start", &encode(&()).unwrap()).unwrap_err();
|
|
818
|
+
assert_eq!(code(&e), Some(codes::FORBIDDEN));
|
|
819
|
+
let _: bool = hub.call_typed(Caller::Player(1), "start", &()).unwrap();
|
|
820
|
+
let hit: bool = hub.call_typed(Caller::Player(1), "cast", &json!({ "ability": "strike", "x": 11.0, "z": 10.0 })).unwrap();
|
|
821
|
+
assert!(hit);
|
|
822
|
+
hub.advance(2_500).unwrap();
|
|
823
|
+
let view: ArenaView = hub.call_typed(Caller::Player(1), "state", &()).unwrap();
|
|
824
|
+
assert_eq!((view.phase, view.winner), (Phase::Finished, None), "no kills: no winner");
|
|
825
|
+
#[derive(Deserialize)]
|
|
826
|
+
struct K {
|
|
827
|
+
kind: ckx_sdk::kit::MessageKind,
|
|
828
|
+
}
|
|
829
|
+
let events: Vec<Message<Value>> = hub
|
|
830
|
+
.published()
|
|
831
|
+
.iter()
|
|
832
|
+
.filter(|(_, b)| decode::<K>(b).unwrap().kind == ckx_sdk::kit::MessageKind::Event)
|
|
833
|
+
.map(|(_, b)| decode(b).unwrap())
|
|
834
|
+
.collect();
|
|
835
|
+
let types: Vec<&str> = events.iter().filter_map(|m| m.body["type"].as_str()).collect();
|
|
836
|
+
assert_eq!(types, vec!["cast", "impact", "finished"]);
|
|
837
|
+
assert!(hub.timers().is_empty());
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
#[test]
|
|
841
|
+
fn an_anchored_arena_sends_its_casts_as_realtime_events() {
|
|
842
|
+
let mut hub = TestHub::<ArenaHub>::spawn_with("arena", "pit", br#"{"anchor":[4,0,4],"spawns":[[10,10],[11,10]]}"#).unwrap();
|
|
843
|
+
let _: bool = hub.call_typed(Caller::Player(1), "join", &json!({ "class": "warrior" })).unwrap();
|
|
844
|
+
let _: bool = hub.call_typed(Caller::Player(2), "join", &json!({ "class": "mage" })).unwrap();
|
|
845
|
+
let _: bool = hub.call_typed(Caller::Player(1), "start", &()).unwrap();
|
|
846
|
+
let _: bool = hub.call_typed(Caller::Player(1), "cast", &json!({ "ability": "strike", "x": 11.0, "z": 10.0 })).unwrap();
|
|
847
|
+
assert_eq!(hub.emitted().len(), 2, "the cast and its impact");
|
|
848
|
+
}
|
|
849
|
+
}
|