@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.
Files changed (61) hide show
  1. package/README.md +13 -1
  2. package/crates/ckx-sdk/Cargo.toml +1 -1
  3. package/crates/ckx-sdk/src/kit/ai/brain.rs +288 -0
  4. package/crates/ckx-sdk/src/kit/ai/flow.rs +163 -0
  5. package/crates/ckx-sdk/src/kit/ai/mod.rs +10 -0
  6. package/crates/ckx-sdk/src/kit/ai/path.rs +214 -0
  7. package/crates/ckx-sdk/src/kit/ai/steering.rs +137 -0
  8. package/crates/ckx-sdk/src/kit/ai/turn.rs +141 -0
  9. package/crates/ckx-sdk/src/kit/mod.rs +542 -0
  10. package/crates/ckx-sdk/src/kit/play/abilities.rs +370 -0
  11. package/crates/ckx-sdk/src/kit/play/cards.rs +220 -0
  12. package/crates/ckx-sdk/src/kit/play/combat.rs +292 -0
  13. package/crates/ckx-sdk/src/kit/play/mod.rs +15 -0
  14. package/crates/ckx-sdk/src/kit/play/movement.rs +196 -0
  15. package/crates/ckx-sdk/src/kit/play/score.rs +183 -0
  16. package/crates/ckx-sdk/src/kit/play/timing.rs +218 -0
  17. package/crates/ckx-sdk/src/kit/play/turns.rs +315 -0
  18. package/crates/ckx-sdk/src/kit/presence.rs +142 -0
  19. package/crates/ckx-sdk/src/kit/rng.rs +137 -0
  20. package/crates/ckx-sdk/src/kit/shard.rs +65 -0
  21. package/crates/ckx-sdk/src/kit/sim/daycycle.rs +71 -0
  22. package/crates/ckx-sdk/src/kit/sim/mod.rs +10 -0
  23. package/crates/ckx-sdk/src/kit/sim/nodes.rs +109 -0
  24. package/crates/ckx-sdk/src/kit/sim/waves.rs +105 -0
  25. package/crates/ckx-sdk/src/kit/sim/weather.rs +142 -0
  26. package/crates/ckx-sdk/src/kit/sim/zones.rs +218 -0
  27. package/crates/ckx-sdk/src/kit/wire.rs +266 -0
  28. package/crates/ckx-sdk/src/lib.rs +13 -0
  29. package/crates/ckx-sdk/src/model.rs +5 -0
  30. package/crates/crowdy-client-sdk/Cargo.toml +1 -1
  31. package/dist/cli/src/new.js +9 -0
  32. package/kit.pin +4 -4
  33. package/package.json +1 -1
  34. package/starters/arena/Cargo.toml +14 -0
  35. package/starters/arena/src/lib.rs +849 -0
  36. package/starters/ckx.json +12 -1
  37. package/starters/deck/Cargo.toml +14 -0
  38. package/starters/deck/src/lib.rs +510 -0
  39. package/starters/director/Cargo.toml +14 -0
  40. package/starters/director/src/lib.rs +628 -0
  41. package/starters/grid-mod/Cargo.toml +1 -1
  42. package/starters/instance/Cargo.toml +14 -0
  43. package/starters/instance/src/lib.rs +656 -0
  44. package/starters/leaderboard/Cargo.toml +14 -0
  45. package/starters/leaderboard/src/lib.rs +395 -0
  46. package/starters/matchmaker/Cargo.toml +1 -1
  47. package/starters/matchmaker/src/lib.rs +334 -74
  48. package/starters/minigame/Cargo.toml +14 -0
  49. package/starters/minigame/src/lib.rs +391 -0
  50. package/starters/npc-mobs/Cargo.toml +1 -1
  51. package/starters/npc-mobs/src/lib.rs +277 -67
  52. package/starters/racing/Cargo.toml +14 -0
  53. package/starters/racing/src/lib.rs +1151 -0
  54. package/starters/session/Cargo.toml +1 -1
  55. package/starters/session/src/lib.rs +191 -86
  56. package/starters/tactics/Cargo.toml +14 -0
  57. package/starters/tactics/src/lib.rs +562 -0
  58. package/starters/territory/Cargo.toml +14 -0
  59. package/starters/territory/src/lib.rs +878 -0
  60. package/starters/world-tick/Cargo.toml +1 -1
  61. package/starters/world-tick/src/lib.rs +151 -38
@@ -9,5 +9,5 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.9.0"
12
+ ckx-sdk = "0.11.0"
13
13
  serde = { version = "1", features = ["derive"] }
@@ -6,16 +6,20 @@
6
6
  //!
7
7
  //! Endpoints (players): `join`, `leave`, `ready { ready }`, `start` (host), `act { points }`
8
8
  //! (the player whose turn it is), `state` (anyone). `configure { .. }` (the host in the lobby,
9
- //! or a developer) and `reserve { players }` (another instance, or a developer). Every change
10
- //! is pushed on the `session` topic.
9
+ //! or a developer) and `reserve { players }` (another instance, or a developer). It speaks the
10
+ //! kit protocol (`ckx_sdk::kit`): every change is a `snapshot` message on the `session` topic,
11
+ //! `snapshot { topic: "session" }` answers the same, a call that changes something may carry
12
+ //! an `action_id`, and refusals carry `kit:<code>:` codes.
11
13
  //!
12
14
  //! [`points_for`] is the game's rule; replace it with yours.
13
15
 
14
16
  use std::collections::{BTreeMap, BTreeSet, VecDeque};
15
17
 
18
+ use ckx_sdk::kit::{self, Publisher, Receipts, codes};
16
19
  use ckx_sdk::prelude::*;
17
20
 
18
21
  const LOG_MOVES: usize = 20;
22
+ pub const TOPIC: &str = "session";
19
23
 
20
24
  #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
21
25
  #[serde(default)]
@@ -60,7 +64,7 @@ pub struct Move {
60
64
 
61
65
  /// The game's rule for what a move scores. The starter's: 0 to 3 points, as asked.
62
66
  pub fn points_for(action: &Action) -> Result<i64> {
63
- if (0..=3).contains(&action.points) { Ok(action.points) } else { Err(Error::new("a move scores 0 to 3 points")) }
67
+ if (0..=3).contains(&action.points) { Ok(action.points) } else { Err(kit::error("invalid_move", "a move scores 0 to 3 points")) }
64
68
  }
65
69
 
66
70
  #[derive(Deserialize)]
@@ -73,6 +77,7 @@ struct Reserve {
73
77
  players: Vec<u64>,
74
78
  }
75
79
 
80
+ /// The session as everyone sees it: what `state` answers and the `session` topic carries.
76
81
  #[derive(Serialize, Deserialize, Default, Debug)]
77
82
  pub struct GameSession {
78
83
  pub config: Config,
@@ -91,7 +96,8 @@ pub struct GameSession {
91
96
  }
92
97
 
93
98
  impl GameSession {
94
- fn now_turn(&self) -> Option<u64> {
99
+ /// Whose turn it is, while the session plays.
100
+ pub fn now_turn(&self) -> Option<u64> {
95
101
  (self.phase == Phase::Playing).then(|| self.players.get(self.turn).copied()).flatten()
96
102
  }
97
103
 
@@ -100,13 +106,13 @@ impl GameSession {
100
106
  return Ok(());
101
107
  }
102
108
  if self.phase != Phase::Lobby {
103
- return Err(Error::new("the session has started"));
109
+ return Err(kit::error(codes::STARTED, "the session has started"));
104
110
  }
105
111
  if !self.reserved.is_empty() && !self.reserved.contains(&player) {
106
- return Err(Error::new("this session is reserved for other players"));
112
+ return Err(kit::error(codes::RESERVED, "this session is reserved for other players"));
107
113
  }
108
114
  if self.players.len() >= self.config.max_players {
109
- return Err(Error::new("the session is full"));
115
+ return Err(kit::error(codes::FULL, "the session is full"));
110
116
  }
111
117
  self.players.push(player);
112
118
  self.scores.insert(player, 0);
@@ -145,16 +151,16 @@ impl GameSession {
145
151
 
146
152
  pub fn start(&mut self, player: u64) -> Result<()> {
147
153
  if self.phase != Phase::Lobby {
148
- return Err(Error::new("the session has started"));
154
+ return Err(kit::error(codes::STARTED, "the session has started"));
149
155
  }
150
156
  if self.host != Some(player) {
151
- return Err(Error::new("only the host starts the session"));
157
+ return Err(kit::error(codes::FORBIDDEN, "only the host starts the session"));
152
158
  }
153
159
  if self.players.len() < self.config.min_players {
154
- return Err(Error::new(format!("at least {} players are needed", self.config.min_players)));
160
+ return Err(kit::error("too_few_players", format!("at least {} players are needed", self.config.min_players)));
155
161
  }
156
162
  if self.players.iter().any(|p| !self.ready.contains(p)) {
157
- return Err(Error::new("everyone must be ready"));
163
+ return Err(kit::error(codes::NOT_READY, "everyone must be ready"));
158
164
  }
159
165
  self.phase = Phase::Playing;
160
166
  self.turn = 0;
@@ -165,7 +171,7 @@ impl GameSession {
165
171
  /// The current player's move, or a timeout (`action` None). Returns whether play goes on.
166
172
  pub fn play(&mut self, player: u64, action: Option<&Action>) -> Result<bool> {
167
173
  if self.now_turn() != Some(player) {
168
- return Err(Error::new("it is not your turn"));
174
+ return Err(kit::error(codes::NOT_YOUR_TURN, "it is not your turn"));
169
175
  }
170
176
  let points = action.map(points_for).transpose()?;
171
177
  let score = self.scores.entry(player).or_insert(0);
@@ -201,140 +207,177 @@ impl GameSession {
201
207
  Ok(())
202
208
  }
203
209
  }
204
- }
205
210
 
206
- impl Hub for GameSession {
207
- fn spawn(ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
208
- let s = Self::default();
209
- ctx.timer_after("idle", s.config.lobby_idle_ms)?;
210
- Ok(s)
211
+ fn set_ready(&mut self, player: u64, ready: bool) -> Result<bool> {
212
+ if self.phase != Phase::Lobby || !self.players.contains(&player) {
213
+ return Err(kit::error(codes::NOT_MEMBER, "join the lobby first"));
214
+ }
215
+ if ready {
216
+ self.ready.insert(player);
217
+ } else {
218
+ self.ready.remove(&player);
219
+ }
220
+ Ok(ready)
211
221
  }
212
222
 
213
- fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
214
- decode(snapshot)
223
+ fn configure(&mut self, caller: Caller, config: Config) -> Result<()> {
224
+ let allowed = match caller {
225
+ Caller::Developer(_) => true,
226
+ Caller::Player(p) => self.host == Some(p),
227
+ _ => false,
228
+ };
229
+ if !allowed || self.phase != Phase::Lobby {
230
+ return Err(kit::error(codes::FORBIDDEN, "the host configures the session in the lobby"));
231
+ }
232
+ if config.min_players < 1 || config.max_players < config.min_players || config.max_players > 64 {
233
+ return Err(kit::error(codes::BAD_REQUEST, "1 <= min_players <= max_players <= 64"));
234
+ }
235
+ if config.turn_ms < 1_000 {
236
+ return Err(kit::error(codes::BAD_REQUEST, "turn_ms is at least 1000"));
237
+ }
238
+ self.config = config;
239
+ Ok(())
215
240
  }
216
241
 
217
- fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
218
- encode(self)
242
+ fn reserve(&mut self, caller: Caller, players: Vec<u64>) -> Result<()> {
243
+ if !matches!(caller, Caller::Instance(_) | Caller::Developer(_)) {
244
+ return Err(kit::error(codes::FORBIDDEN, "reserve is for the matchmaker"));
245
+ }
246
+ if self.phase != Phase::Lobby || !self.players.is_empty() {
247
+ return Err(kit::error(codes::CONFLICT, "the session is already in use"));
248
+ }
249
+ if players.is_empty() || players.len() > self.config.max_players {
250
+ return Err(kit::error(codes::BAD_REQUEST, "reserve 1 to max_players players"));
251
+ }
252
+ self.reserved = players;
253
+ Ok(())
219
254
  }
255
+ }
220
256
 
221
- fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
257
+ /// The hub: the session, and what the kit protocol keeps beside it.
258
+ #[derive(Serialize, Deserialize, Default, Debug)]
259
+ pub struct SessionHub {
260
+ #[serde(flatten)]
261
+ pub game: GameSession,
262
+ #[serde(default)]
263
+ pub kit: Publisher,
264
+ #[serde(default)]
265
+ pub receipts: Receipts,
266
+ }
267
+
268
+ impl SessionHub {
269
+ fn act(&mut self, ctx: &Ctx, call: &Call<'_>) -> Result<Vec<u8>> {
270
+ let game = &mut self.game;
222
271
  let out = match call.method {
223
- "state" => return encode(&*self),
224
272
  "join" => {
225
- self.join(call.player()?)?;
273
+ game.join(call.player()?)?;
226
274
  ctx.cancel_timer("idle");
227
275
  encode(&true)
228
276
  }
229
277
  "leave" => {
230
- if self.leave(call.player()?) {
231
- self.arm_turn(ctx)?;
278
+ if game.leave(call.player()?) {
279
+ game.arm_turn(ctx)?;
232
280
  }
233
281
  encode(&true)
234
282
  }
235
283
  "ready" => {
236
- let player = call.player()?;
237
- let req: Ready = call.decode()?;
238
- if self.phase != Phase::Lobby || !self.players.contains(&player) {
239
- return Err(Error::new("join the lobby first"));
240
- }
241
- if req.ready {
242
- self.ready.insert(player);
243
- } else {
244
- self.ready.remove(&player);
245
- }
246
- encode(&req.ready)
284
+ let req: Ready = kit::args(call)?;
285
+ encode(&game.set_ready(call.player()?, req.ready)?)
247
286
  }
248
287
  "start" => {
249
- self.start(call.player()?)?;
250
- self.arm_turn(ctx)?;
288
+ game.start(call.player()?)?;
289
+ game.arm_turn(ctx)?;
251
290
  encode(&true)
252
291
  }
253
292
  "act" => {
254
293
  let player = call.player()?;
255
- let action: Action = call.decode()?;
256
- let more = self.play(player, Some(&action))?;
257
- self.arm_turn(ctx)?;
294
+ let action: Action = kit::args(call)?;
295
+ let more = game.play(player, Some(&action))?;
296
+ game.arm_turn(ctx)?;
258
297
  encode(&more)
259
298
  }
260
299
  "configure" => {
261
- let allowed = match call.caller {
262
- Caller::Developer(_) => true,
263
- Caller::Player(p) => self.host == Some(p),
264
- _ => false,
265
- };
266
- if !allowed || self.phase != Phase::Lobby {
267
- return Err(Error::new("the host configures the session in the lobby"));
268
- }
269
- let config: Config = call.decode()?;
270
- if config.min_players < 1 || config.max_players < config.min_players || config.max_players > 64 {
271
- return Err(Error::new("1 <= min_players <= max_players <= 64"));
272
- }
273
- if config.turn_ms < 1_000 {
274
- return Err(Error::new("turn_ms is at least 1000"));
275
- }
276
- self.config = config;
300
+ game.configure(call.caller, kit::args(call)?)?;
277
301
  encode(&true)
278
302
  }
279
303
  "reserve" => {
280
- if !matches!(call.caller, Caller::Instance(_) | Caller::Developer(_)) {
281
- return Err(Error::new("reserve is for the matchmaker"));
282
- }
283
- let req: Reserve = call.decode()?;
284
- if self.phase != Phase::Lobby || !self.players.is_empty() {
285
- return Err(Error::new("the session is already in use"));
286
- }
287
- if req.players.is_empty() || req.players.len() > self.config.max_players {
288
- return Err(Error::new("reserve 1 to max_players players"));
289
- }
290
- self.reserved = req.players;
304
+ let req: Reserve = kit::args(call)?;
305
+ game.reserve(call.caller, req.players)?;
291
306
  encode(&true)
292
307
  }
293
308
  other => return Err(Error::unknown_method(other)),
294
309
  };
295
- ctx.publish("session", &encode(&*self)?);
310
+ self.kit.snapshot(ctx, TOPIC, &self.game)?;
296
311
  ctx.persist_now();
297
312
  out
298
313
  }
314
+ }
315
+
316
+ impl Hub for SessionHub {
317
+ fn spawn(ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
318
+ let s = Self::default();
319
+ ctx.timer_after("idle", s.game.config.lobby_idle_ms)?;
320
+ Ok(s)
321
+ }
322
+
323
+ /// Reads this version's snapshots and the earlier ones, which held the session alone.
324
+ fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
325
+ decode(snapshot)
326
+ }
327
+
328
+ fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
329
+ encode(self)
330
+ }
331
+
332
+ fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
333
+ match call.method {
334
+ "state" => encode(&self.game),
335
+ kit::SNAPSHOT_METHOD => self.kit.snapshot_reply(ctx, TOPIC, &self.game),
336
+ _ => Receipts::run(self, |h| &mut h.receipts, &call, ctx.now_ms(), |h| h.act(ctx, &call)),
337
+ }
338
+ }
299
339
 
300
340
  fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
341
+ let game = &mut self.game;
301
342
  match timer {
302
343
  "turn" => {
303
- if let Some(player) = self.now_turn() {
304
- self.play(player, None)?;
305
- self.arm_turn(ctx)?;
344
+ if let Some(player) = game.now_turn() {
345
+ game.play(player, None)?;
346
+ game.arm_turn(ctx)?;
306
347
  }
307
348
  }
308
- "idle" if self.phase == Phase::Lobby && self.players.is_empty() => self.phase = Phase::Finished,
349
+ "idle" if game.phase == Phase::Lobby && game.players.is_empty() => game.phase = Phase::Finished,
309
350
  _ => return Ok(()),
310
351
  }
311
- ctx.publish("session", &encode(&*self)?);
312
- Ok(())
352
+ self.kit.snapshot(ctx, TOPIC, &self.game)
313
353
  }
314
354
 
315
- fn on_session(&mut self, _ctx: &Ctx, player: u64, session: Session) -> Result<()> {
355
+ fn on_session(&mut self, ctx: &Ctx, player: u64, session: Session) -> Result<()> {
356
+ let game = &mut self.game;
316
357
  match session {
317
358
  Session::Joined => {
318
- self.connected.insert(player);
359
+ game.connected.insert(player);
319
360
  }
320
361
  Session::Left => {
321
- self.connected.remove(&player);
322
- if self.host == Some(player)
323
- && let Some(next) = self.players.iter().find(|p| self.connected.contains(p))
362
+ game.connected.remove(&player);
363
+ if game.host == Some(player)
364
+ && let Some(next) = game.players.iter().find(|p| game.connected.contains(p))
324
365
  {
325
- self.host = Some(*next);
366
+ game.host = Some(*next);
326
367
  }
327
368
  }
328
369
  }
329
- Ok(())
370
+ self.kit.snapshot(ctx, TOPIC, &self.game)
330
371
  }
331
372
  }
332
373
 
333
- ckx_sdk::export_hub!(GameSession);
374
+ ckx_sdk::export_hub!(SessionHub);
334
375
 
335
376
  #[cfg(test)]
336
377
  mod tests {
337
378
  use super::*;
379
+ use ckx_sdk::kit::{Message, MessageKind, error_code};
380
+ use ckx_sdk::testing::TestHub;
338
381
 
339
382
  fn lobby(players: &[u64]) -> GameSession {
340
383
  let mut s = GameSession::default();
@@ -376,7 +419,9 @@ mod tests {
376
419
  #[test]
377
420
  fn a_reserved_session_admits_only_its_players() {
378
421
  let mut s = GameSession { reserved: vec![1, 2], ..Default::default() };
379
- assert!(s.join(3).is_err());
422
+ let Error(why) = s.join(3).unwrap_err();
423
+ assert_eq!(error_code(&why).map(|c| c.0), Some(codes::RESERVED));
424
+ assert!(why.contains("reserved"));
380
425
  s.join(2).unwrap();
381
426
  assert_eq!(s.host, Some(2));
382
427
  }
@@ -392,4 +437,64 @@ mod tests {
392
437
  assert!(!s.leave(2));
393
438
  assert_eq!((s.phase, s.winner), (Phase::Finished, Some(3)));
394
439
  }
440
+
441
+ #[derive(Serialize)]
442
+ struct Act<'a> {
443
+ points: i64,
444
+ action_id: &'a str,
445
+ }
446
+
447
+ #[test]
448
+ fn every_change_is_a_snapshot_message_and_an_action_sent_again_runs_once() {
449
+ let mut hub = TestHub::<SessionHub>::spawn("session", "s1").unwrap();
450
+ for p in [1, 2] {
451
+ let _: bool = hub.call_typed(Caller::Player(p), "join", &()).unwrap();
452
+ let _: bool = hub.call_typed(Caller::Player(p), "ready", &Ready2 { ready: true }).unwrap();
453
+ }
454
+ let _: bool = hub.call_typed(Caller::Player(1), "start", &()).unwrap();
455
+ let more: bool = hub.call_typed(Caller::Player(1), "act", &Act { points: 3, action_id: "m1" }).unwrap();
456
+ assert!(more);
457
+ // The same action again (a lost reply): answered from the receipt, not played twice.
458
+ let again: bool = hub.call_typed(Caller::Player(1), "act", &Act { points: 3, action_id: "m1" }).unwrap();
459
+ assert!(again);
460
+ assert_eq!(hub.hub().game.scores[&1], 3);
461
+ assert_eq!(hub.hub().game.now_turn(), Some(2));
462
+
463
+ let published = hub.published();
464
+ assert!(published.iter().all(|(t, _)| t == TOPIC));
465
+ let seqs: Vec<u64> = published.iter().map(|(_, b)| decode::<Message<PhaseOnly>>(b).unwrap().header.seq).collect();
466
+ assert_eq!(seqs, (1..=seqs.len() as u64).collect::<Vec<_>>(), "one message a change, in order");
467
+ let last: Message<PhaseOnly> = decode(&published.last().unwrap().1).unwrap();
468
+ assert_eq!(last.header.kind, MessageKind::Snapshot);
469
+ assert_eq!(last.body.phase, Phase::Playing);
470
+
471
+ let snap: Message<PhaseOnly> = hub.call_typed(Caller::Player(2), "snapshot", &Topic { topic: TOPIC }).unwrap();
472
+ assert_eq!((snap.header.seq, snap.header.kind), (seqs.len() as u64, MessageKind::Snapshot));
473
+
474
+ let e = hub.call(Caller::Player(1), "act", &encode(&Act { points: 1, action_id: "m2" }).unwrap()).unwrap_err();
475
+ assert_eq!(error_code(&e.message).map(|c| c.0), Some(codes::NOT_YOUR_TURN));
476
+ }
477
+
478
+ #[test]
479
+ fn a_snapshot_from_before_the_kit_protocol_loads() {
480
+ let old = encode(&lobby(&[4, 5])).unwrap();
481
+ let hub = TestHub::<SessionHub>::load("session", "s1", &old, 1).unwrap();
482
+ assert_eq!(hub.hub().game.players, vec![4, 5]);
483
+ assert!(hub.hub().receipts.is_empty());
484
+ }
485
+
486
+ #[derive(Serialize)]
487
+ struct Ready2 {
488
+ ready: bool,
489
+ }
490
+
491
+ #[derive(Deserialize)]
492
+ struct PhaseOnly {
493
+ phase: Phase,
494
+ }
495
+
496
+ #[derive(Serialize)]
497
+ struct Topic<'a> {
498
+ topic: &'a str,
499
+ }
395
500
  }
@@ -0,0 +1,14 @@
1
+ [package]
2
+ name = "tactics"
3
+ description = "Starter: a turn-based grid battle, players' units against the hub's, with reach, ranges and an enemy phase"
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"