@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,628 @@
|
|
|
1
|
+
//! An encounter, keyed by its id: waves of enemies down a lane to the players' base, towers the
|
|
2
|
+
//! players build, and a boss that changes phase as it weakens. The tower-defense and horde
|
|
3
|
+
//! shape: enemies follow one flow field (`ckx_sdk::kit::ai::flow`) from the spawn to the base, a
|
|
4
|
+
//! tower may not cut the lane, towers strike what comes into range, players strike too, kills
|
|
5
|
+
//! pay gold, and an enemy that reaches the base takes from its health. The waves
|
|
6
|
+
//! (`ckx_sdk::kit::sim::waves`) start when the last is cleared, after a delay; the last brings the
|
|
7
|
+
//! boss. Replace the [`Config`] with your game's lane, waves and numbers.
|
|
8
|
+
//!
|
|
9
|
+
//! Endpoints (players): `join`, `start` (the first to join, or a developer), `build { x, z }`,
|
|
10
|
+
//! `hit { enemy }`, `state` (anyone). It speaks the kit protocol (`ckx_sdk::kit`): the `encounter`
|
|
11
|
+
//! topic carries the encounter as `snapshot` messages once a second while it runs, and `event`
|
|
12
|
+
//! messages (`type`: `wave_started`, `wave_cleared`, `boss_phase`, `won`, `lost`) as they happen;
|
|
13
|
+
//! `snapshot { topic: "encounter" }` answers it; changes may carry an `action_id`; refusals carry
|
|
14
|
+
//! `kit:<code>:` codes.
|
|
15
|
+
|
|
16
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
17
|
+
|
|
18
|
+
use ckx_sdk::kit::ai::flow::FlowField;
|
|
19
|
+
use ckx_sdk::kit::ai::path::GridProvider;
|
|
20
|
+
use ckx_sdk::kit::play::combat::Cooldowns;
|
|
21
|
+
use ckx_sdk::kit::sim::waves::{WaveDef, WaveEvent, WavePhase, WaveSchedule};
|
|
22
|
+
use ckx_sdk::kit::{self, Publisher, Receipts, codes};
|
|
23
|
+
use ckx_sdk::prelude::*;
|
|
24
|
+
use serde_json::json;
|
|
25
|
+
|
|
26
|
+
pub const TOPIC: &str = "encounter";
|
|
27
|
+
pub const TICK_MS: u64 = 500;
|
|
28
|
+
/// Ticks between `encounter` snapshots.
|
|
29
|
+
const PUBLISH_EVERY: u64 = 2;
|
|
30
|
+
const MAX_ENEMIES: usize = 200;
|
|
31
|
+
|
|
32
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
33
|
+
pub struct EnemyKind {
|
|
34
|
+
pub id: String,
|
|
35
|
+
pub hp: i64,
|
|
36
|
+
/// Ticks between steps (1 is a cell a tick).
|
|
37
|
+
pub step_ticks: u32,
|
|
38
|
+
/// Base health it takes on arrival.
|
|
39
|
+
pub leak: i64,
|
|
40
|
+
/// Gold its kill pays.
|
|
41
|
+
pub bounty: i64,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
45
|
+
#[serde(default)]
|
|
46
|
+
pub struct Config {
|
|
47
|
+
pub width: i32,
|
|
48
|
+
pub height: i32,
|
|
49
|
+
pub spawn: (i32, i32),
|
|
50
|
+
pub base: (i32, i32),
|
|
51
|
+
pub base_hp: i64,
|
|
52
|
+
pub gold: i64,
|
|
53
|
+
pub tower_cost: i64,
|
|
54
|
+
/// Damage a tower deals a tick, and how far it reaches (cells, Manhattan).
|
|
55
|
+
pub tower_damage: i64,
|
|
56
|
+
pub tower_range: i32,
|
|
57
|
+
/// A player's strike: damage and cooldown.
|
|
58
|
+
pub hit_damage: i64,
|
|
59
|
+
pub hit_cooldown_ms: u64,
|
|
60
|
+
pub kinds: Vec<EnemyKind>,
|
|
61
|
+
pub waves: Vec<WaveDef>,
|
|
62
|
+
/// The kind the last wave's boss is; none for no boss.
|
|
63
|
+
pub boss: Option<EnemyKind>,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
impl Default for Config {
|
|
67
|
+
fn default() -> Self {
|
|
68
|
+
let grunt = EnemyKind { id: "grunt".into(), hp: 20, step_ticks: 2, leak: 1, bounty: 5 };
|
|
69
|
+
let runner = EnemyKind { id: "runner".into(), hp: 10, step_ticks: 1, leak: 1, bounty: 4 };
|
|
70
|
+
Self {
|
|
71
|
+
width: 12,
|
|
72
|
+
height: 8,
|
|
73
|
+
spawn: (0, 4),
|
|
74
|
+
base: (11, 4),
|
|
75
|
+
base_hp: 10,
|
|
76
|
+
gold: 30,
|
|
77
|
+
tower_cost: 10,
|
|
78
|
+
tower_damage: 4,
|
|
79
|
+
tower_range: 2,
|
|
80
|
+
hit_damage: 5,
|
|
81
|
+
hit_cooldown_ms: 500,
|
|
82
|
+
kinds: vec![grunt, runner],
|
|
83
|
+
waves: vec![
|
|
84
|
+
WaveDef { units: vec![("grunt".into(), 4)], delay_ms: 3_000 },
|
|
85
|
+
WaveDef { units: vec![("grunt".into(), 4), ("runner".into(), 3)], delay_ms: 5_000 },
|
|
86
|
+
WaveDef { units: vec![("runner".into(), 6)], delay_ms: 5_000 },
|
|
87
|
+
],
|
|
88
|
+
boss: Some(EnemyKind { id: "warlord".into(), hp: 200, step_ticks: 3, leak: 5, bounty: 50 }),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
94
|
+
#[serde(rename_all = "snake_case")]
|
|
95
|
+
pub enum Phase {
|
|
96
|
+
#[default]
|
|
97
|
+
Lobby,
|
|
98
|
+
Running,
|
|
99
|
+
Won,
|
|
100
|
+
Lost,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
104
|
+
pub struct Enemy {
|
|
105
|
+
pub id: u32,
|
|
106
|
+
pub kind: String,
|
|
107
|
+
pub x: i32,
|
|
108
|
+
pub z: i32,
|
|
109
|
+
pub hp: i64,
|
|
110
|
+
pub max_hp: i64,
|
|
111
|
+
pub boss: bool,
|
|
112
|
+
/// Ticks until its next step.
|
|
113
|
+
pub wait: u32,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
117
|
+
pub struct Tower {
|
|
118
|
+
pub x: i32,
|
|
119
|
+
pub z: i32,
|
|
120
|
+
pub owner: u64,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// The encounter as everyone sees it.
|
|
124
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
125
|
+
pub struct EncounterView {
|
|
126
|
+
pub phase: Phase,
|
|
127
|
+
pub players: Vec<u64>,
|
|
128
|
+
pub width: i32,
|
|
129
|
+
pub height: i32,
|
|
130
|
+
pub spawn: (i32, i32),
|
|
131
|
+
pub base: (i32, i32),
|
|
132
|
+
pub base_hp: i64,
|
|
133
|
+
pub gold: i64,
|
|
134
|
+
pub wave: Option<usize>,
|
|
135
|
+
pub waves: usize,
|
|
136
|
+
/// When the next wave starts, while one counts down.
|
|
137
|
+
pub next_wave_at: Option<u64>,
|
|
138
|
+
pub enemies: Vec<Enemy>,
|
|
139
|
+
pub towers: Vec<Tower>,
|
|
140
|
+
pub boss_phase: u32,
|
|
141
|
+
pub kills: BTreeMap<u64, u32>,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#[derive(Deserialize)]
|
|
145
|
+
struct Build {
|
|
146
|
+
x: i32,
|
|
147
|
+
z: i32,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#[derive(Deserialize)]
|
|
151
|
+
struct Hit {
|
|
152
|
+
enemy: u32,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
156
|
+
pub struct Encounter {
|
|
157
|
+
pub config: Config,
|
|
158
|
+
pub phase: Phase,
|
|
159
|
+
pub players: Vec<u64>,
|
|
160
|
+
pub base_hp: i64,
|
|
161
|
+
pub gold: i64,
|
|
162
|
+
pub schedule: WaveSchedule,
|
|
163
|
+
pub enemies: Vec<Enemy>,
|
|
164
|
+
pub towers: Vec<Tower>,
|
|
165
|
+
pub next_id: u32,
|
|
166
|
+
pub ticks: u64,
|
|
167
|
+
pub boss_phase: u32,
|
|
168
|
+
pub kills: BTreeMap<u64, u32>,
|
|
169
|
+
pub cooldowns: Cooldowns,
|
|
170
|
+
/// Events since they were last published.
|
|
171
|
+
#[serde(skip)]
|
|
172
|
+
pub events: Vec<serde_json::Value>,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
fn manhattan(a: (i32, i32), b: (i32, i32)) -> i32 {
|
|
176
|
+
(a.0 - b.0).abs() + (a.1 - b.1).abs()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
impl Encounter {
|
|
180
|
+
fn towers_at(&self) -> BTreeSet<(i32, i32)> {
|
|
181
|
+
self.towers.iter().map(|t| (t.x, t.z)).collect()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/// The lane: every free cell's distance to the base.
|
|
185
|
+
pub fn field(&self, extra: Option<(i32, i32)>) -> FlowField {
|
|
186
|
+
let mut blocked = self.towers_at();
|
|
187
|
+
blocked.extend(extra);
|
|
188
|
+
let is_blocked = |x: i32, z: i32| blocked.contains(&(x, z));
|
|
189
|
+
let grid = GridProvider { width: self.config.width, height: self.config.height, blocked: &is_blocked };
|
|
190
|
+
FlowField::build(&grid, (0, 0, self.config.width, self.config.height), &[self.config.base], 4_096)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
pub fn view(&self, now: u64) -> EncounterView {
|
|
194
|
+
let (wave, next_wave_at) = match &self.schedule.phase {
|
|
195
|
+
WavePhase::Active { index } => (Some(*index), None),
|
|
196
|
+
WavePhase::Countdown { next, starts_at_ms } => (next.checked_sub(1), Some(*starts_at_ms)),
|
|
197
|
+
WavePhase::Complete => (None, None),
|
|
198
|
+
};
|
|
199
|
+
let _ = now;
|
|
200
|
+
EncounterView {
|
|
201
|
+
phase: self.phase,
|
|
202
|
+
players: self.players.clone(),
|
|
203
|
+
width: self.config.width,
|
|
204
|
+
height: self.config.height,
|
|
205
|
+
spawn: self.config.spawn,
|
|
206
|
+
base: self.config.base,
|
|
207
|
+
base_hp: self.base_hp,
|
|
208
|
+
gold: self.gold,
|
|
209
|
+
wave,
|
|
210
|
+
waves: self.config.waves.len(),
|
|
211
|
+
next_wave_at,
|
|
212
|
+
enemies: self.enemies.clone(),
|
|
213
|
+
towers: self.towers.clone(),
|
|
214
|
+
boss_phase: self.boss_phase,
|
|
215
|
+
kills: self.kills.clone(),
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
fn event(&mut self, kind: &str, extra: serde_json::Value) {
|
|
220
|
+
let mut e = json!({ "type": kind });
|
|
221
|
+
if let (Some(e), Some(x)) = (e.as_object_mut(), extra.as_object()) {
|
|
222
|
+
e.extend(x.clone());
|
|
223
|
+
}
|
|
224
|
+
self.events.push(e);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
pub fn join(&mut self, player: u64) -> Result<()> {
|
|
228
|
+
if !self.players.contains(&player) {
|
|
229
|
+
if self.players.len() >= 8 {
|
|
230
|
+
return Err(kit::error(codes::FULL, "the encounter is full"));
|
|
231
|
+
}
|
|
232
|
+
self.players.push(player);
|
|
233
|
+
}
|
|
234
|
+
Ok(())
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
pub fn start(&mut self, now: u64) -> Result<()> {
|
|
238
|
+
if self.phase == Phase::Running {
|
|
239
|
+
return Err(kit::error(codes::STARTED, "the encounter is running"));
|
|
240
|
+
}
|
|
241
|
+
if self.field(None).distance(self.config.spawn.0, self.config.spawn.1).is_none() {
|
|
242
|
+
return Err(kit::error(codes::BAD_REQUEST, "the spawn has no way to the base"));
|
|
243
|
+
}
|
|
244
|
+
self.phase = Phase::Running;
|
|
245
|
+
self.base_hp = self.config.base_hp;
|
|
246
|
+
self.gold = self.config.gold;
|
|
247
|
+
self.enemies.clear();
|
|
248
|
+
self.towers.clear();
|
|
249
|
+
self.boss_phase = 0;
|
|
250
|
+
self.kills.clear();
|
|
251
|
+
self.schedule = WaveSchedule::start(&self.config.waves, now);
|
|
252
|
+
Ok(())
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
pub fn build(&mut self, player: u64, at: (i32, i32)) -> Result<i64> {
|
|
256
|
+
if self.phase != Phase::Running {
|
|
257
|
+
return Err(kit::error(codes::CONFLICT, "the encounter is not running"));
|
|
258
|
+
}
|
|
259
|
+
if !self.players.contains(&player) {
|
|
260
|
+
return Err(kit::error(codes::NOT_MEMBER, "join the encounter first"));
|
|
261
|
+
}
|
|
262
|
+
let (w, h) = (self.config.width, self.config.height);
|
|
263
|
+
if at.0 < 0 || at.1 < 0 || at.0 >= w || at.1 >= h || at == self.config.spawn || at == self.config.base {
|
|
264
|
+
return Err(kit::error(codes::BAD_REQUEST, "build on a free cell of the field"));
|
|
265
|
+
}
|
|
266
|
+
if self.towers_at().contains(&at) || self.enemies.iter().any(|e| (e.x, e.z) == at) {
|
|
267
|
+
return Err(kit::error(codes::CONFLICT, "that cell is taken"));
|
|
268
|
+
}
|
|
269
|
+
if self.gold < self.config.tower_cost {
|
|
270
|
+
return Err(kit::error(codes::INSUFFICIENT, "not enough gold"));
|
|
271
|
+
}
|
|
272
|
+
if self.field(Some(at)).distance(self.config.spawn.0, self.config.spawn.1).is_none() {
|
|
273
|
+
return Err(kit::error("blocks_lane", "a tower there would cut the lane"));
|
|
274
|
+
}
|
|
275
|
+
self.gold -= self.config.tower_cost;
|
|
276
|
+
self.towers.push(Tower { x: at.0, z: at.1, owner: player });
|
|
277
|
+
Ok(self.gold)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
pub fn hit(&mut self, player: u64, enemy: u32, now: u64) -> Result<i64> {
|
|
281
|
+
if !self.players.contains(&player) {
|
|
282
|
+
return Err(kit::error(codes::NOT_MEMBER, "join the encounter first"));
|
|
283
|
+
}
|
|
284
|
+
if !self.cooldowns.take(&player.to_string(), "hit", self.config.hit_cooldown_ms, now) {
|
|
285
|
+
return Err(kit::error(codes::COOLDOWN, "striking too fast"));
|
|
286
|
+
}
|
|
287
|
+
let damage = self.config.hit_damage;
|
|
288
|
+
let i = self.enemies.iter().position(|e| e.id == enemy).ok_or_else(|| kit::error(codes::NOT_FOUND, format!("no enemy {enemy}")))?;
|
|
289
|
+
self.enemies[i].hp -= damage;
|
|
290
|
+
let left = self.enemies[i].hp.max(0);
|
|
291
|
+
self.reap(Some(player));
|
|
292
|
+
Ok(left)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
fn spawn_wave(&mut self, index: usize) {
|
|
296
|
+
let wave = self.config.waves.get(index).cloned();
|
|
297
|
+
let last = index + 1 == self.config.waves.len();
|
|
298
|
+
let (sx, sz) = self.config.spawn;
|
|
299
|
+
for (kind, count) in wave.map(|w| w.units).unwrap_or_default() {
|
|
300
|
+
let Some(k) = self.config.kinds.iter().find(|k| k.id == kind).cloned() else { continue };
|
|
301
|
+
for _ in 0..count {
|
|
302
|
+
self.add(&k, false, (sx, sz));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if last && let Some(boss) = self.config.boss.clone() {
|
|
306
|
+
self.add(&boss, true, (sx, sz));
|
|
307
|
+
}
|
|
308
|
+
self.event("wave_started", json!({ "wave": index }));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
fn add(&mut self, k: &EnemyKind, boss: bool, at: (i32, i32)) {
|
|
312
|
+
if self.enemies.len() >= MAX_ENEMIES {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
self.next_id += 1;
|
|
316
|
+
// Enemies wait their turn at the spawn so they come one after another.
|
|
317
|
+
let queued = self.enemies.iter().filter(|e| (e.x, e.z) == at).count() as u32;
|
|
318
|
+
self.enemies.push(Enemy {
|
|
319
|
+
id: self.next_id,
|
|
320
|
+
kind: k.id.clone(),
|
|
321
|
+
x: at.0,
|
|
322
|
+
z: at.1,
|
|
323
|
+
hp: k.hp,
|
|
324
|
+
max_hp: k.hp,
|
|
325
|
+
boss,
|
|
326
|
+
wait: k.step_ticks.max(1) * queued,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
fn kind(&self, e: &Enemy) -> EnemyKind {
|
|
331
|
+
if e.boss {
|
|
332
|
+
return self.config.boss.clone().unwrap_or(EnemyKind { id: e.kind.clone(), hp: e.max_hp, step_ticks: 3, leak: 5, bounty: 50 });
|
|
333
|
+
}
|
|
334
|
+
self.config.kinds.iter().find(|k| k.id == e.kind).cloned().unwrap_or(EnemyKind {
|
|
335
|
+
id: e.kind.clone(),
|
|
336
|
+
hp: e.max_hp,
|
|
337
|
+
step_ticks: 2,
|
|
338
|
+
leak: 1,
|
|
339
|
+
bounty: 0,
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/// Takes the dead off the field and pays their bounty.
|
|
344
|
+
fn reap(&mut self, killer: Option<u64>) {
|
|
345
|
+
let dead: Vec<Enemy> = self.enemies.iter().filter(|e| e.hp <= 0).cloned().collect();
|
|
346
|
+
self.enemies.retain(|e| e.hp > 0);
|
|
347
|
+
for e in dead {
|
|
348
|
+
self.gold += self.kind(&e).bounty;
|
|
349
|
+
if let Some(p) = killer {
|
|
350
|
+
*self.kills.entry(p).or_insert(0) += 1;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if let Some(boss) = self.enemies.iter().find(|e| e.boss) {
|
|
354
|
+
let phase = match boss.hp * 4 / boss.max_hp.max(1) {
|
|
355
|
+
3.. => 0,
|
|
356
|
+
2 => 1,
|
|
357
|
+
1 => 2,
|
|
358
|
+
_ => 3,
|
|
359
|
+
};
|
|
360
|
+
if phase > self.boss_phase {
|
|
361
|
+
self.boss_phase = phase;
|
|
362
|
+
self.event("boss_phase", json!({ "phase": phase }));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/// One tick: waves, steps, towers, leaks. Returns whether the encounter ran.
|
|
368
|
+
pub fn tick(&mut self, now: u64) -> bool {
|
|
369
|
+
if self.phase != Phase::Running {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
self.ticks += 1;
|
|
373
|
+
let field = self.field(None);
|
|
374
|
+
let mut leaked = 0;
|
|
375
|
+
let paces: Vec<u32> = self.enemies.iter().map(|e| self.kind(e).step_ticks.max(1)).collect();
|
|
376
|
+
for (e, pace) in self.enemies.iter_mut().zip(paces) {
|
|
377
|
+
if e.wait > 0 {
|
|
378
|
+
e.wait -= 1;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if let Some((dx, dz)) = field.step(e.x, e.z) {
|
|
382
|
+
e.x += dx;
|
|
383
|
+
e.z += dz;
|
|
384
|
+
}
|
|
385
|
+
e.wait = pace - 1;
|
|
386
|
+
}
|
|
387
|
+
let base = self.config.base;
|
|
388
|
+
let arriving: Vec<Enemy> = self.enemies.iter().filter(|e| (e.x, e.z) == base).cloned().collect();
|
|
389
|
+
self.enemies.retain(|e| (e.x, e.z) != base);
|
|
390
|
+
for e in &arriving {
|
|
391
|
+
leaked += self.kind(e).leak;
|
|
392
|
+
}
|
|
393
|
+
let towers = self.towers.clone();
|
|
394
|
+
for t in &towers {
|
|
395
|
+
let target = self
|
|
396
|
+
.enemies
|
|
397
|
+
.iter_mut()
|
|
398
|
+
.filter(|e| manhattan((e.x, e.z), (t.x, t.z)) <= self.config.tower_range)
|
|
399
|
+
.min_by_key(|e| (manhattan((e.x, e.z), base), e.id));
|
|
400
|
+
if let Some(e) = target {
|
|
401
|
+
e.hp -= self.config.tower_damage;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
self.reap(None);
|
|
405
|
+
self.base_hp -= leaked;
|
|
406
|
+
if self.base_hp <= 0 {
|
|
407
|
+
self.base_hp = 0;
|
|
408
|
+
self.phase = Phase::Lost;
|
|
409
|
+
self.event("lost", json!({}));
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
let cleared = self.enemies.is_empty();
|
|
413
|
+
let was = self.schedule.phase.clone();
|
|
414
|
+
match self.schedule.step(&self.config.waves, now, cleared) {
|
|
415
|
+
Some(WaveEvent::Start(i)) => {
|
|
416
|
+
if let WavePhase::Active { index } = was {
|
|
417
|
+
self.event("wave_cleared", json!({ "wave": index }));
|
|
418
|
+
}
|
|
419
|
+
self.spawn_wave(i);
|
|
420
|
+
}
|
|
421
|
+
Some(WaveEvent::Finished) => {
|
|
422
|
+
self.phase = Phase::Won;
|
|
423
|
+
self.event("won", json!({}));
|
|
424
|
+
}
|
|
425
|
+
None => {
|
|
426
|
+
if let (WavePhase::Active { index }, WavePhase::Countdown { .. }) = (&was, &self.schedule.phase) {
|
|
427
|
+
let index = *index;
|
|
428
|
+
self.event("wave_cleared", json!({ "wave": index }));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
true
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/// The hub: the encounter, and what the kit protocol keeps beside it.
|
|
437
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
438
|
+
pub struct EncounterHub {
|
|
439
|
+
#[serde(flatten)]
|
|
440
|
+
pub encounter: Encounter,
|
|
441
|
+
#[serde(default)]
|
|
442
|
+
pub kit: Publisher,
|
|
443
|
+
#[serde(default)]
|
|
444
|
+
pub receipts: Receipts,
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
impl EncounterHub {
|
|
448
|
+
fn act(&mut self, ctx: &Ctx, call: &Call<'_>, now: u64) -> Result<Vec<u8>> {
|
|
449
|
+
let e = &mut self.encounter;
|
|
450
|
+
let out = match call.method {
|
|
451
|
+
"join" => {
|
|
452
|
+
e.join(call.player()?)?;
|
|
453
|
+
encode(&true)
|
|
454
|
+
}
|
|
455
|
+
"start" => {
|
|
456
|
+
let allowed = match call.caller {
|
|
457
|
+
Caller::Developer(_) => true,
|
|
458
|
+
Caller::Player(p) => e.players.first() == Some(&p),
|
|
459
|
+
_ => false,
|
|
460
|
+
};
|
|
461
|
+
if !allowed {
|
|
462
|
+
return Err(kit::error(codes::FORBIDDEN, "the first to join, or a developer, starts it"));
|
|
463
|
+
}
|
|
464
|
+
e.start(now)?;
|
|
465
|
+
ctx.timer_every("tick", TICK_MS)?;
|
|
466
|
+
encode(&true)
|
|
467
|
+
}
|
|
468
|
+
"build" => {
|
|
469
|
+
let b: Build = kit::args(call)?;
|
|
470
|
+
encode(&e.build(call.player()?, (b.x, b.z))?)
|
|
471
|
+
}
|
|
472
|
+
"hit" => {
|
|
473
|
+
let h: Hit = kit::args(call)?;
|
|
474
|
+
encode(&e.hit(call.player()?, h.enemy, now)?)
|
|
475
|
+
}
|
|
476
|
+
other => return Err(Error::unknown_method(other)),
|
|
477
|
+
};
|
|
478
|
+
self.publish(ctx, now)?;
|
|
479
|
+
out
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
fn publish(&mut self, ctx: &Ctx, now: u64) -> Result<()> {
|
|
483
|
+
for e in std::mem::take(&mut self.encounter.events) {
|
|
484
|
+
self.kit.event(ctx, TOPIC, &e)?;
|
|
485
|
+
}
|
|
486
|
+
self.kit.snapshot(ctx, TOPIC, &self.encounter.view(now))
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
impl Hub for EncounterHub {
|
|
491
|
+
fn spawn(_ctx: &Ctx, seed: &[u8]) -> Result<Self> {
|
|
492
|
+
let mut hub = Self::default();
|
|
493
|
+
if !seed.is_empty() {
|
|
494
|
+
hub.encounter.config = serde_json::from_slice(seed).map_err(|e| Error::new(format!("seed is not a JSON config: {e}")))?;
|
|
495
|
+
}
|
|
496
|
+
hub.encounter.base_hp = hub.encounter.config.base_hp;
|
|
497
|
+
hub.encounter.gold = hub.encounter.config.gold;
|
|
498
|
+
Ok(hub)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
|
|
502
|
+
decode(snapshot)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
|
|
506
|
+
encode(self)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
510
|
+
let now = ctx.now_ms();
|
|
511
|
+
match call.method {
|
|
512
|
+
"state" => encode(&self.encounter.view(now)),
|
|
513
|
+
kit::SNAPSHOT_METHOD => self.kit.snapshot_reply(ctx, TOPIC, &self.encounter.view(now)),
|
|
514
|
+
_ => Receipts::run(self, |h| &mut h.receipts, &call, now, |h| h.act(ctx, &call, now)),
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
|
|
519
|
+
if timer != "tick" {
|
|
520
|
+
return Ok(());
|
|
521
|
+
}
|
|
522
|
+
let now = ctx.now_ms();
|
|
523
|
+
if !self.encounter.tick(now) {
|
|
524
|
+
ctx.cancel_timer("tick");
|
|
525
|
+
return Ok(());
|
|
526
|
+
}
|
|
527
|
+
let ended = self.encounter.phase != Phase::Running;
|
|
528
|
+
if ended {
|
|
529
|
+
ctx.cancel_timer("tick");
|
|
530
|
+
ctx.persist_now();
|
|
531
|
+
}
|
|
532
|
+
if ended || !self.encounter.events.is_empty() || self.encounter.ticks.is_multiple_of(PUBLISH_EVERY) {
|
|
533
|
+
self.publish(ctx, now)?;
|
|
534
|
+
}
|
|
535
|
+
Ok(())
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
ckx_sdk::export_hub!(EncounterHub);
|
|
540
|
+
|
|
541
|
+
#[cfg(test)]
|
|
542
|
+
mod tests {
|
|
543
|
+
use super::*;
|
|
544
|
+
use ckx_sdk::kit::{Message, MessageKind, error_code};
|
|
545
|
+
use ckx_sdk::testing::TestHub;
|
|
546
|
+
|
|
547
|
+
fn running() -> Encounter {
|
|
548
|
+
let mut e = Encounter::default();
|
|
549
|
+
e.join(1).unwrap();
|
|
550
|
+
e.start(0).unwrap();
|
|
551
|
+
e
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
#[test]
|
|
555
|
+
fn a_tower_may_not_cut_the_lane_and_costs_gold() {
|
|
556
|
+
let mut e =
|
|
557
|
+
Encounter { config: Config { width: 3, height: 3, spawn: (0, 1), base: (2, 1), ..Config::default() }, ..Default::default() };
|
|
558
|
+
e.join(1).unwrap();
|
|
559
|
+
e.start(0).unwrap();
|
|
560
|
+
e.build(1, (1, 0)).unwrap();
|
|
561
|
+
e.build(1, (1, 2)).unwrap();
|
|
562
|
+
let Error(why) = e.build(1, (1, 1)).unwrap_err();
|
|
563
|
+
assert_eq!(error_code(&why).map(|c| c.0), Some("blocks_lane"));
|
|
564
|
+
assert_eq!(e.gold, Config::default().gold - 2 * Config::default().tower_cost);
|
|
565
|
+
e.gold = 0;
|
|
566
|
+
let Error(why) = e.build(1, (0, 0)).unwrap_err();
|
|
567
|
+
assert_eq!(error_code(&why).map(|c| c.0), Some(codes::INSUFFICIENT));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
#[test]
|
|
571
|
+
fn with_no_towers_the_waves_leak_into_the_base_until_it_falls() {
|
|
572
|
+
let mut e = running();
|
|
573
|
+
let mut now = 0;
|
|
574
|
+
while e.phase == Phase::Running && now < 600_000 {
|
|
575
|
+
now += TICK_MS;
|
|
576
|
+
e.tick(now);
|
|
577
|
+
}
|
|
578
|
+
assert_eq!(e.phase, Phase::Lost);
|
|
579
|
+
assert!(e.events.iter().any(|x| x["type"] == "wave_started"));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
#[test]
|
|
583
|
+
fn towers_along_the_lane_hold_it_and_the_boss_turns_its_phases() {
|
|
584
|
+
let mut e = running();
|
|
585
|
+
e.gold = 10_000;
|
|
586
|
+
for x in 1..11 {
|
|
587
|
+
for z in [3, 5] {
|
|
588
|
+
e.build(1, (x, z)).unwrap();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
let mut now = 0;
|
|
592
|
+
while e.phase == Phase::Running && now < 3_600_000 {
|
|
593
|
+
now += TICK_MS;
|
|
594
|
+
e.tick(now);
|
|
595
|
+
}
|
|
596
|
+
assert_eq!(e.phase, Phase::Won, "base {} gold {}", e.base_hp, e.gold);
|
|
597
|
+
assert!(e.events.iter().any(|x| x["type"] == "boss_phase"));
|
|
598
|
+
assert!(e.events.iter().any(|x| x["type"] == "wave_cleared"));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
#[test]
|
|
602
|
+
fn players_strike_on_a_cooldown_and_the_topic_carries_snapshots_and_events() {
|
|
603
|
+
let mut hub = TestHub::<EncounterHub>::spawn("director", "gate").unwrap();
|
|
604
|
+
let _: bool = hub.call_typed(Caller::Player(1), "join", &()).unwrap();
|
|
605
|
+
let e = hub.call(Caller::Player(2), "start", &encode(&()).unwrap()).unwrap_err();
|
|
606
|
+
assert_eq!(error_code(&e.message).map(|c| c.0), Some(codes::FORBIDDEN));
|
|
607
|
+
let _: bool = hub.call_typed(Caller::Player(1), "start", &()).unwrap();
|
|
608
|
+
hub.advance(4_000).unwrap();
|
|
609
|
+
let view: EncounterView = hub.call_typed(Caller::Player(1), "state", &()).unwrap();
|
|
610
|
+
let enemy = view.enemies.first().expect("the first wave is out").id;
|
|
611
|
+
let _: i64 = hub.call_typed(Caller::Player(1), "hit", &serde_json::json!({ "enemy": enemy })).unwrap();
|
|
612
|
+
let e = hub.call(Caller::Player(1), "hit", &encode(&serde_json::json!({ "enemy": enemy })).unwrap()).unwrap_err();
|
|
613
|
+
assert_eq!(error_code(&e.message).map(|c| c.0), Some(codes::COOLDOWN));
|
|
614
|
+
#[derive(Deserialize)]
|
|
615
|
+
struct K {
|
|
616
|
+
kind: MessageKind,
|
|
617
|
+
}
|
|
618
|
+
let kinds: Vec<MessageKind> = hub.published().iter().map(|(_, b)| decode::<K>(b).unwrap().kind).collect();
|
|
619
|
+
assert!(kinds.contains(&MessageKind::Event) && kinds.contains(&MessageKind::Snapshot));
|
|
620
|
+
let started: Vec<Message<serde_json::Value>> = hub
|
|
621
|
+
.published()
|
|
622
|
+
.iter()
|
|
623
|
+
.filter(|(_, b)| decode::<K>(b).unwrap().kind == MessageKind::Event)
|
|
624
|
+
.map(|(_, b)| decode(b).unwrap())
|
|
625
|
+
.collect();
|
|
626
|
+
assert_eq!(started[0].body["type"], "wave_started");
|
|
627
|
+
}
|
|
628
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "instance"
|
|
3
|
+
description = "Starter: a private run for one party, floors generated from the run's seed, permadeath, an idle expiry, an optional reserved grid volume, and its depth posted to a leaderboard"
|
|
4
|
+
version = "0.1.0"
|
|
5
|
+
edition = "2024"
|
|
6
|
+
publish = false
|
|
7
|
+
|
|
8
|
+
[lib]
|
|
9
|
+
crate-type = ["cdylib"]
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
ckx-sdk = "0.11.0"
|
|
13
|
+
serde = { version = "1", features = ["derive"] }
|
|
14
|
+
serde_json = "1"
|