@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,562 @@
|
|
|
1
|
+
//! A grid battle, keyed by its id: players' units against the hub's, in turns. Each player who
|
|
2
|
+
//! joins brings a unit; the host starts, and enemies take the far side. On the players' phase each
|
|
3
|
+
//! unit may move (within its move points, around obstacles and other units) and attack once; when
|
|
4
|
+
//! every player has ended their turn, or the phase's time runs out, the enemies move and strike
|
|
5
|
+
//! (`ckx_sdk::kit::ai::turn`), and the round turns. The battle ends when one side has no unit
|
|
6
|
+
//! standing. Replace [`damage`] and the [`Config`] with your game's numbers.
|
|
7
|
+
//!
|
|
8
|
+
//! Endpoints (players): `join`, `start` (the host), `move { x, z }`, `attack { target }`,
|
|
9
|
+
//! `end_turn`, `state` (anyone). It speaks the kit protocol (`ckx_sdk::kit`): the `battle` topic
|
|
10
|
+
//! carries the battle as `snapshot` messages, and so does `snapshot { topic: "battle" }`; changes
|
|
11
|
+
//! may carry an `action_id`; refusals carry `kit:<code>:` codes.
|
|
12
|
+
|
|
13
|
+
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
|
14
|
+
|
|
15
|
+
use ckx_sdk::kit::ai::path::GridProvider;
|
|
16
|
+
use ckx_sdk::kit::ai::turn::{approach_scorer, best_move, reachable};
|
|
17
|
+
use ckx_sdk::kit::{self, Publisher, Receipts, Rng, codes};
|
|
18
|
+
use ckx_sdk::prelude::*;
|
|
19
|
+
|
|
20
|
+
pub const TOPIC: &str = "battle";
|
|
21
|
+
const MAX_PLAYERS: usize = 4;
|
|
22
|
+
const LOG: usize = 24;
|
|
23
|
+
const EXPAND: usize = 512;
|
|
24
|
+
|
|
25
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
26
|
+
#[serde(default)]
|
|
27
|
+
pub struct Config {
|
|
28
|
+
pub width: i32,
|
|
29
|
+
pub height: i32,
|
|
30
|
+
/// Obstacles scattered at the start, out of every 100 cells.
|
|
31
|
+
pub obstacles_pct: u32,
|
|
32
|
+
pub phase_ms: u64,
|
|
33
|
+
pub player_unit: UnitStats,
|
|
34
|
+
pub enemy_unit: UnitStats,
|
|
35
|
+
/// Enemies per player, plus one.
|
|
36
|
+
pub enemies_per_player: u32,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
impl Default for Config {
|
|
40
|
+
fn default() -> Self {
|
|
41
|
+
Self {
|
|
42
|
+
width: 8,
|
|
43
|
+
height: 8,
|
|
44
|
+
obstacles_pct: 10,
|
|
45
|
+
phase_ms: 60_000,
|
|
46
|
+
player_unit: UnitStats { hp: 30, attack: 9, defense: 3, moves: 4, range: 1 },
|
|
47
|
+
enemy_unit: UnitStats { hp: 18, attack: 7, defense: 2, moves: 3, range: 1 },
|
|
48
|
+
enemies_per_player: 1,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
|
|
54
|
+
pub struct UnitStats {
|
|
55
|
+
pub hp: i64,
|
|
56
|
+
pub attack: i64,
|
|
57
|
+
pub defense: i64,
|
|
58
|
+
pub moves: u32,
|
|
59
|
+
/// How far an attack reaches, in cells (Manhattan).
|
|
60
|
+
pub range: i32,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
|
|
64
|
+
#[serde(rename_all = "snake_case")]
|
|
65
|
+
pub enum Phase {
|
|
66
|
+
#[default]
|
|
67
|
+
Lobby,
|
|
68
|
+
Players,
|
|
69
|
+
Enemies,
|
|
70
|
+
Won,
|
|
71
|
+
Lost,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
|
75
|
+
#[serde(rename_all = "snake_case")]
|
|
76
|
+
pub enum Team {
|
|
77
|
+
Players,
|
|
78
|
+
Enemies,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
82
|
+
pub struct Unit {
|
|
83
|
+
pub id: u32,
|
|
84
|
+
pub team: Team,
|
|
85
|
+
/// The player it belongs to; none for the hub's.
|
|
86
|
+
pub owner: Option<u64>,
|
|
87
|
+
pub x: i32,
|
|
88
|
+
pub z: i32,
|
|
89
|
+
pub hp: i64,
|
|
90
|
+
pub stats: UnitStats,
|
|
91
|
+
pub moved: bool,
|
|
92
|
+
pub acted: bool,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
impl Unit {
|
|
96
|
+
fn alive(&self) -> bool {
|
|
97
|
+
self.hp > 0
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// What a strike does: attack less defense, at least 1. The starter's rule.
|
|
102
|
+
pub fn damage(attacker: &UnitStats, defender: &UnitStats) -> i64 {
|
|
103
|
+
(attacker.attack - defender.defense).max(1)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
fn manhattan(a: (i32, i32), b: (i32, i32)) -> i32 {
|
|
107
|
+
(a.0 - b.0).abs() + (a.1 - b.1).abs()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
111
|
+
pub struct Battle {
|
|
112
|
+
pub config: Config,
|
|
113
|
+
pub phase: Phase,
|
|
114
|
+
pub players: Vec<u64>,
|
|
115
|
+
pub host: Option<u64>,
|
|
116
|
+
pub round: u32,
|
|
117
|
+
pub obstacles: BTreeSet<(i32, i32)>,
|
|
118
|
+
pub units: Vec<Unit>,
|
|
119
|
+
pub ended_turn: BTreeSet<u64>,
|
|
120
|
+
pub phase_ends: u64,
|
|
121
|
+
pub log: VecDeque<String>,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// The battle as everyone sees it.
|
|
125
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
126
|
+
pub struct BattleView {
|
|
127
|
+
pub phase: Phase,
|
|
128
|
+
pub round: u32,
|
|
129
|
+
pub width: i32,
|
|
130
|
+
pub height: i32,
|
|
131
|
+
pub obstacles: Vec<(i32, i32)>,
|
|
132
|
+
pub units: Vec<Unit>,
|
|
133
|
+
pub players: Vec<u64>,
|
|
134
|
+
pub host: Option<u64>,
|
|
135
|
+
pub ended_turn: Vec<u64>,
|
|
136
|
+
pub phase_ends: Option<u64>,
|
|
137
|
+
pub log: Vec<String>,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#[derive(Deserialize)]
|
|
141
|
+
struct Move {
|
|
142
|
+
x: i32,
|
|
143
|
+
z: i32,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#[derive(Deserialize)]
|
|
147
|
+
struct Attack {
|
|
148
|
+
target: u32,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
impl Battle {
|
|
152
|
+
pub fn view(&self) -> BattleView {
|
|
153
|
+
BattleView {
|
|
154
|
+
phase: self.phase,
|
|
155
|
+
round: self.round,
|
|
156
|
+
width: self.config.width,
|
|
157
|
+
height: self.config.height,
|
|
158
|
+
obstacles: self.obstacles.iter().copied().collect(),
|
|
159
|
+
units: self.units.clone(),
|
|
160
|
+
players: self.players.clone(),
|
|
161
|
+
host: self.host,
|
|
162
|
+
ended_turn: self.ended_turn.iter().copied().collect(),
|
|
163
|
+
phase_ends: matches!(self.phase, Phase::Players).then_some(self.phase_ends),
|
|
164
|
+
log: self.log.iter().cloned().collect(),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
fn note(&mut self, line: String) {
|
|
169
|
+
self.log.push_back(line);
|
|
170
|
+
while self.log.len() > LOG {
|
|
171
|
+
self.log.pop_front();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
fn blocked(&self, at: (i32, i32), ignore: u32) -> bool {
|
|
176
|
+
self.obstacles.contains(&at) || self.units.iter().any(|u| u.alive() && u.id != ignore && (u.x, u.z) == at)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
pub fn join(&mut self, player: u64) -> Result<()> {
|
|
180
|
+
if self.players.contains(&player) {
|
|
181
|
+
return Ok(());
|
|
182
|
+
}
|
|
183
|
+
if self.phase != Phase::Lobby {
|
|
184
|
+
return Err(kit::error(codes::STARTED, "the battle has started"));
|
|
185
|
+
}
|
|
186
|
+
if self.players.len() >= MAX_PLAYERS {
|
|
187
|
+
return Err(kit::error(codes::FULL, "the battle is full"));
|
|
188
|
+
}
|
|
189
|
+
self.players.push(player);
|
|
190
|
+
self.host.get_or_insert(player);
|
|
191
|
+
Ok(())
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// Lays out the field and the units.
|
|
195
|
+
pub fn start(&mut self, player: u64, seed: u64, now: u64) -> Result<()> {
|
|
196
|
+
if self.phase != Phase::Lobby {
|
|
197
|
+
return Err(kit::error(codes::STARTED, "the battle has started"));
|
|
198
|
+
}
|
|
199
|
+
if self.host != Some(player) {
|
|
200
|
+
return Err(kit::error(codes::FORBIDDEN, "only the host starts the battle"));
|
|
201
|
+
}
|
|
202
|
+
let (w, h) = (self.config.width.clamp(4, 32), self.config.height.clamp(4, 32));
|
|
203
|
+
self.config.width = w;
|
|
204
|
+
self.config.height = h;
|
|
205
|
+
let mut rng = Rng::seeded(seed);
|
|
206
|
+
self.units.clear();
|
|
207
|
+
let mut next_id = 1;
|
|
208
|
+
for (i, p) in self.players.iter().enumerate() {
|
|
209
|
+
let z = (i as i32 * 2 + 1).min(h - 1);
|
|
210
|
+
self.units.push(Unit {
|
|
211
|
+
id: next_id,
|
|
212
|
+
team: Team::Players,
|
|
213
|
+
owner: Some(*p),
|
|
214
|
+
x: 0,
|
|
215
|
+
z,
|
|
216
|
+
hp: self.config.player_unit.hp,
|
|
217
|
+
stats: self.config.player_unit,
|
|
218
|
+
moved: false,
|
|
219
|
+
acted: false,
|
|
220
|
+
});
|
|
221
|
+
next_id += 1;
|
|
222
|
+
}
|
|
223
|
+
let enemies = (self.players.len() as u32 * self.config.enemies_per_player + 1).min(h as u32);
|
|
224
|
+
for i in 0..enemies as i32 {
|
|
225
|
+
let z = (i * 2).min(h - 1);
|
|
226
|
+
self.units.push(Unit {
|
|
227
|
+
id: next_id,
|
|
228
|
+
team: Team::Enemies,
|
|
229
|
+
owner: None,
|
|
230
|
+
x: w - 1,
|
|
231
|
+
z,
|
|
232
|
+
hp: self.config.enemy_unit.hp,
|
|
233
|
+
stats: self.config.enemy_unit,
|
|
234
|
+
moved: false,
|
|
235
|
+
acted: false,
|
|
236
|
+
});
|
|
237
|
+
next_id += 1;
|
|
238
|
+
}
|
|
239
|
+
// Obstacles in the middle columns, never on a unit.
|
|
240
|
+
self.obstacles.clear();
|
|
241
|
+
for x in 2..w - 2 {
|
|
242
|
+
for z in 0..h {
|
|
243
|
+
if rng.below(100) < u64::from(self.config.obstacles_pct) && !self.blocked((x, z), 0) {
|
|
244
|
+
self.obstacles.insert((x, z));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
self.round = 1;
|
|
249
|
+
self.begin_players(now);
|
|
250
|
+
Ok(())
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
fn begin_players(&mut self, now: u64) {
|
|
254
|
+
self.phase = Phase::Players;
|
|
255
|
+
self.ended_turn.clear();
|
|
256
|
+
self.phase_ends = now + self.config.phase_ms;
|
|
257
|
+
for u in &mut self.units {
|
|
258
|
+
u.moved = false;
|
|
259
|
+
u.acted = false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
fn unit_of(&self, player: u64) -> Result<usize> {
|
|
264
|
+
if self.phase != Phase::Players {
|
|
265
|
+
return Err(kit::error(codes::NOT_YOUR_TURN, "it is not the players' phase"));
|
|
266
|
+
}
|
|
267
|
+
if self.ended_turn.contains(&player) {
|
|
268
|
+
return Err(kit::error(codes::NOT_YOUR_TURN, "you ended your turn"));
|
|
269
|
+
}
|
|
270
|
+
self.units
|
|
271
|
+
.iter()
|
|
272
|
+
.position(|u| u.owner == Some(player) && u.alive())
|
|
273
|
+
.ok_or_else(|| kit::error(codes::NOT_MEMBER, "you have no unit standing"))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
pub fn move_to(&mut self, player: u64, to: (i32, i32)) -> Result<()> {
|
|
277
|
+
let i = self.unit_of(player)?;
|
|
278
|
+
let u = self.units[i].clone();
|
|
279
|
+
if u.moved {
|
|
280
|
+
return Err(kit::error(codes::CONFLICT, "the unit has moved this turn"));
|
|
281
|
+
}
|
|
282
|
+
let (w, h) = (self.config.width, self.config.height);
|
|
283
|
+
let blocked = |x: i32, z: i32| self.blocked((x, z), u.id);
|
|
284
|
+
let grid = GridProvider { width: w, height: h, blocked: &blocked };
|
|
285
|
+
if !reachable(&grid, (u.x, u.z), u.stats.moves, EXPAND).iter().any(|c| (c.x, c.z) == to) {
|
|
286
|
+
return Err(kit::error(codes::OUT_OF_REACH, "that cell is out of the unit's reach"));
|
|
287
|
+
}
|
|
288
|
+
let unit = &mut self.units[i];
|
|
289
|
+
unit.x = to.0;
|
|
290
|
+
unit.z = to.1;
|
|
291
|
+
unit.moved = true;
|
|
292
|
+
self.note(format!("unit {} moved to {},{}", u.id, to.0, to.1));
|
|
293
|
+
Ok(())
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
pub fn attack(&mut self, player: u64, target: u32) -> Result<i64> {
|
|
297
|
+
let i = self.unit_of(player)?;
|
|
298
|
+
let me = self.units[i].clone();
|
|
299
|
+
if me.acted {
|
|
300
|
+
return Err(kit::error(codes::CONFLICT, "the unit has attacked this turn"));
|
|
301
|
+
}
|
|
302
|
+
let t = self
|
|
303
|
+
.units
|
|
304
|
+
.iter()
|
|
305
|
+
.position(|u| u.id == target && u.alive())
|
|
306
|
+
.ok_or_else(|| kit::error(codes::NOT_FOUND, "no such unit standing"))?;
|
|
307
|
+
if self.units[t].team == me.team {
|
|
308
|
+
return Err(kit::error(codes::FORBIDDEN, "that unit is on your side"));
|
|
309
|
+
}
|
|
310
|
+
if manhattan((me.x, me.z), (self.units[t].x, self.units[t].z)) > me.stats.range {
|
|
311
|
+
return Err(kit::error(codes::OUT_OF_REACH, "out of reach"));
|
|
312
|
+
}
|
|
313
|
+
let dealt = damage(&me.stats, &self.units[t].stats);
|
|
314
|
+
let left = {
|
|
315
|
+
let target = &mut self.units[t];
|
|
316
|
+
target.hp = (target.hp - dealt).max(0);
|
|
317
|
+
target.hp
|
|
318
|
+
};
|
|
319
|
+
self.units[i].acted = true;
|
|
320
|
+
self.note(format!("unit {} hit unit {target} for {dealt}", me.id));
|
|
321
|
+
self.judge();
|
|
322
|
+
Ok(left)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
pub fn end_turn(&mut self, player: u64, now: u64) -> Result<()> {
|
|
326
|
+
if self.phase != Phase::Players || !self.players.contains(&player) {
|
|
327
|
+
return Err(kit::error(codes::NOT_YOUR_TURN, "it is not the players' phase"));
|
|
328
|
+
}
|
|
329
|
+
self.ended_turn.insert(player);
|
|
330
|
+
let standing: Vec<u64> = self.units.iter().filter(|u| u.alive()).filter_map(|u| u.owner).collect();
|
|
331
|
+
if standing.iter().all(|p| self.ended_turn.contains(p)) {
|
|
332
|
+
self.enemy_phase(now);
|
|
333
|
+
}
|
|
334
|
+
Ok(())
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/// The hub's units: each moves toward the nearest player's unit and strikes it if it can.
|
|
338
|
+
pub fn enemy_phase(&mut self, now: u64) {
|
|
339
|
+
if self.phase != Phase::Players {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
self.phase = Phase::Enemies;
|
|
343
|
+
let ids: Vec<u32> = self.units.iter().filter(|u| u.team == Team::Enemies && u.alive()).map(|u| u.id).collect();
|
|
344
|
+
for id in ids {
|
|
345
|
+
let targets: Vec<(i32, i32)> = self.units.iter().filter(|u| u.team == Team::Players && u.alive()).map(|u| (u.x, u.z)).collect();
|
|
346
|
+
if targets.is_empty() {
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
let me = self.units.iter().find(|u| u.id == id).cloned().expect("listed above");
|
|
350
|
+
let near = targets.iter().any(|t| manhattan(*t, (me.x, me.z)) <= me.stats.range);
|
|
351
|
+
if !near {
|
|
352
|
+
let (w, h) = (self.config.width, self.config.height);
|
|
353
|
+
let blocked = |x: i32, z: i32| self.blocked((x, z), id);
|
|
354
|
+
let grid = GridProvider { width: w, height: h, blocked: &blocked };
|
|
355
|
+
if let Some(m) = best_move(&grid, (me.x, me.z), me.stats.moves, EXPAND, approach_scorer(targets.clone())) {
|
|
356
|
+
let u = self.units.iter_mut().find(|u| u.id == id).expect("listed above");
|
|
357
|
+
u.x = m.to.0;
|
|
358
|
+
u.z = m.to.1;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
let me = self.units.iter().find(|u| u.id == id).cloned().expect("listed above");
|
|
362
|
+
let victim = self
|
|
363
|
+
.units
|
|
364
|
+
.iter()
|
|
365
|
+
.filter(|u| u.team == Team::Players && u.alive() && manhattan((u.x, u.z), (me.x, me.z)) <= me.stats.range)
|
|
366
|
+
.min_by_key(|u| (u.hp, u.id))
|
|
367
|
+
.map(|u| u.id);
|
|
368
|
+
if let Some(v) = victim {
|
|
369
|
+
let target = self.units.iter_mut().find(|u| u.id == v).expect("found above");
|
|
370
|
+
let dealt = damage(&me.stats, &target.stats);
|
|
371
|
+
target.hp = (target.hp - dealt).max(0);
|
|
372
|
+
self.note(format!("unit {id} hit unit {v} for {dealt}"));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
self.judge();
|
|
376
|
+
if self.phase == Phase::Enemies {
|
|
377
|
+
self.round += 1;
|
|
378
|
+
self.begin_players(now);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
fn judge(&mut self) {
|
|
383
|
+
let alive = |t: Team| self.units.iter().any(|u| u.team == t && u.alive());
|
|
384
|
+
if !alive(Team::Enemies) {
|
|
385
|
+
self.phase = Phase::Won;
|
|
386
|
+
} else if !alive(Team::Players) {
|
|
387
|
+
self.phase = Phase::Lost;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/// The hub: the battle, and what the kit protocol keeps beside it.
|
|
393
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
394
|
+
pub struct BattleHub {
|
|
395
|
+
#[serde(flatten)]
|
|
396
|
+
pub battle: Battle,
|
|
397
|
+
#[serde(default)]
|
|
398
|
+
pub kit: Publisher,
|
|
399
|
+
#[serde(default)]
|
|
400
|
+
pub receipts: Receipts,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
impl BattleHub {
|
|
404
|
+
fn act(&mut self, ctx: &Ctx, call: &Call<'_>, now: u64) -> Result<Vec<u8>> {
|
|
405
|
+
let player = call.player()?;
|
|
406
|
+
let b = &mut self.battle;
|
|
407
|
+
let out = match call.method {
|
|
408
|
+
"join" => {
|
|
409
|
+
b.join(player)?;
|
|
410
|
+
encode(&true)
|
|
411
|
+
}
|
|
412
|
+
"start" => {
|
|
413
|
+
b.start(player, Rng::seed_from(&ctx.key) ^ ctx.epoch, now)?;
|
|
414
|
+
encode(&true)
|
|
415
|
+
}
|
|
416
|
+
"move" => {
|
|
417
|
+
let m: Move = kit::args(call)?;
|
|
418
|
+
b.move_to(player, (m.x, m.z))?;
|
|
419
|
+
encode(&true)
|
|
420
|
+
}
|
|
421
|
+
"attack" => {
|
|
422
|
+
let a: Attack = kit::args(call)?;
|
|
423
|
+
encode(&b.attack(player, a.target)?)
|
|
424
|
+
}
|
|
425
|
+
"end_turn" => {
|
|
426
|
+
b.end_turn(player, now)?;
|
|
427
|
+
encode(&true)
|
|
428
|
+
}
|
|
429
|
+
other => return Err(Error::unknown_method(other)),
|
|
430
|
+
};
|
|
431
|
+
self.arm(ctx)?;
|
|
432
|
+
self.kit.snapshot(ctx, TOPIC, &self.battle.view())?;
|
|
433
|
+
ctx.persist_now();
|
|
434
|
+
out
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
fn arm(&self, ctx: &Ctx) -> Result<()> {
|
|
438
|
+
if self.battle.phase == Phase::Players {
|
|
439
|
+
ctx.timer_after("phase", self.battle.phase_ends.saturating_sub(ctx.now_ms()).max(1))
|
|
440
|
+
} else {
|
|
441
|
+
ctx.cancel_timer("phase");
|
|
442
|
+
Ok(())
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
impl Hub for BattleHub {
|
|
448
|
+
fn spawn(_ctx: &Ctx, seed: &[u8]) -> Result<Self> {
|
|
449
|
+
let mut hub = Self::default();
|
|
450
|
+
if !seed.is_empty() {
|
|
451
|
+
hub.battle.config = serde_json::from_slice(seed).map_err(|e| Error::new(format!("seed is not a JSON config: {e}")))?;
|
|
452
|
+
}
|
|
453
|
+
Ok(hub)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
|
|
457
|
+
decode(snapshot)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
|
|
461
|
+
encode(self)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
465
|
+
let now = ctx.now_ms();
|
|
466
|
+
match call.method {
|
|
467
|
+
"state" => encode(&self.battle.view()),
|
|
468
|
+
kit::SNAPSHOT_METHOD => self.kit.snapshot_reply(ctx, TOPIC, &self.battle.view()),
|
|
469
|
+
_ => Receipts::run(self, |h| &mut h.receipts, &call, now, |h| h.act(ctx, &call, now)),
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
|
|
474
|
+
if timer != "phase" || self.battle.phase != Phase::Players {
|
|
475
|
+
return Ok(());
|
|
476
|
+
}
|
|
477
|
+
self.battle.enemy_phase(ctx.now_ms());
|
|
478
|
+
self.arm(ctx)?;
|
|
479
|
+
self.kit.snapshot(ctx, TOPIC, &self.battle.view())
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
ckx_sdk::export_hub!(BattleHub);
|
|
484
|
+
|
|
485
|
+
/// Cells a unit can reach, for a client that highlights them (the hub checks again).
|
|
486
|
+
pub fn reach_of(battle: &Battle, unit: u32) -> Vec<(i32, i32)> {
|
|
487
|
+
let Some(u) = battle.units.iter().find(|u| u.id == unit) else { return Vec::new() };
|
|
488
|
+
let blocked = |x: i32, z: i32| battle.blocked((x, z), unit);
|
|
489
|
+
let grid = GridProvider { width: battle.config.width, height: battle.config.height, blocked: &blocked };
|
|
490
|
+
reachable(&grid, (u.x, u.z), u.stats.moves, EXPAND).into_iter().map(|c| (c.x, c.z)).collect::<BTreeSet<_>>().into_iter().collect()
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/// Units by team, for a quick read.
|
|
494
|
+
pub fn standing(battle: &Battle) -> BTreeMap<String, usize> {
|
|
495
|
+
let mut out = BTreeMap::new();
|
|
496
|
+
for u in battle.units.iter().filter(|u| u.alive()) {
|
|
497
|
+
*out.entry(format!("{:?}", u.team).to_lowercase()).or_insert(0) += 1;
|
|
498
|
+
}
|
|
499
|
+
out
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
#[cfg(test)]
|
|
503
|
+
mod tests {
|
|
504
|
+
use super::*;
|
|
505
|
+
use ckx_sdk::kit::{Message, error_code};
|
|
506
|
+
use ckx_sdk::testing::TestHub;
|
|
507
|
+
|
|
508
|
+
fn battle() -> Battle {
|
|
509
|
+
let mut b = Battle::default();
|
|
510
|
+
b.join(1).unwrap();
|
|
511
|
+
b.start(1, 3, 0).unwrap();
|
|
512
|
+
b
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
#[test]
|
|
516
|
+
fn a_unit_moves_within_its_reach_and_attacks_once_in_range() {
|
|
517
|
+
let mut b = battle();
|
|
518
|
+
let Error(why) = b.move_to(1, (7, 7)).unwrap_err();
|
|
519
|
+
assert_eq!(error_code(&why).map(|c| c.0), Some(codes::OUT_OF_REACH));
|
|
520
|
+
let reach = reach_of(&b, 1);
|
|
521
|
+
let step = *reach.iter().find(|c| **c != (0, 1)).unwrap();
|
|
522
|
+
b.move_to(1, step).unwrap();
|
|
523
|
+
assert!(b.move_to(1, (0, 1)).is_err(), "one move a turn");
|
|
524
|
+
let enemy = b.units.iter().find(|u| u.team == Team::Enemies).unwrap().id;
|
|
525
|
+
let Error(why) = b.attack(1, enemy).unwrap_err();
|
|
526
|
+
assert_eq!(error_code(&why).map(|c| c.0), Some(codes::OUT_OF_REACH));
|
|
527
|
+
assert_eq!(standing(&b)["enemies"], 2);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
#[test]
|
|
531
|
+
fn the_enemies_close_in_and_strike_and_one_side_falls() {
|
|
532
|
+
let mut b = battle();
|
|
533
|
+
let mut now = 0;
|
|
534
|
+
for _ in 0..40 {
|
|
535
|
+
if !matches!(b.phase, Phase::Players) {
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
// The player's unit holds; it strikes whatever comes into reach.
|
|
539
|
+
let me = b.units.iter().find(|u| u.owner == Some(1)).cloned().unwrap();
|
|
540
|
+
if let Some(t) = b.units.iter().find(|u| u.team == Team::Enemies && u.alive() && manhattan((u.x, u.z), (me.x, me.z)) <= 1) {
|
|
541
|
+
let id = t.id;
|
|
542
|
+
b.attack(1, id).unwrap();
|
|
543
|
+
}
|
|
544
|
+
now += 1_000;
|
|
545
|
+
b.end_turn(1, now).unwrap();
|
|
546
|
+
}
|
|
547
|
+
assert!(matches!(b.phase, Phase::Won | Phase::Lost), "{:?}", b.phase);
|
|
548
|
+
assert!(b.log.iter().any(|l| l.contains("hit")));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
#[test]
|
|
552
|
+
fn the_phase_times_out_into_the_enemies_turn() {
|
|
553
|
+
let mut hub = TestHub::<BattleHub>::spawn("tactics", "b1").unwrap();
|
|
554
|
+
let _: bool = hub.call_typed(Caller::Player(1), "join", &()).unwrap();
|
|
555
|
+
let _: bool = hub.call_typed(Caller::Player(1), "start", &()).unwrap();
|
|
556
|
+
let before: BattleView = hub.call_typed(Caller::Player(1), "state", &()).unwrap();
|
|
557
|
+
hub.advance(Config::default().phase_ms).unwrap();
|
|
558
|
+
let msg: Message<BattleView> = decode(&hub.published().last().unwrap().1).unwrap();
|
|
559
|
+
assert_eq!((before.round, msg.body.round), (1, 2));
|
|
560
|
+
assert_ne!(before.units, msg.body.units, "the enemies moved");
|
|
561
|
+
}
|
|
562
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "territory"
|
|
3
|
+
description = "Starter for two types: `territory`, control points factions capture by standing on them, with income and a winning score; and `zone`, a shrinking circle that wears down the players outside it until one is left"
|
|
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"
|