@crowdedkingdoms/ckx-kit 0.3.0-dev.1 → 0.5.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 (68) hide show
  1. package/README.md +9 -2
  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/econ/holdings.rs +241 -0
  10. package/crates/ckx-sdk/src/kit/econ/holds.rs +202 -0
  11. package/crates/ckx-sdk/src/kit/econ/items.rs +212 -0
  12. package/crates/ckx-sdk/src/kit/econ/loot.rs +116 -0
  13. package/crates/ckx-sdk/src/kit/econ/mod.rs +50 -0
  14. package/crates/ckx-sdk/src/kit/econ/orderbook.rs +155 -0
  15. package/crates/ckx-sdk/src/kit/econ/production.rs +155 -0
  16. package/crates/ckx-sdk/src/kit/econ/progression.rs +254 -0
  17. package/crates/ckx-sdk/src/kit/econ/recipes.rs +65 -0
  18. package/crates/ckx-sdk/src/kit/econ/vendor.rs +116 -0
  19. package/crates/ckx-sdk/src/kit/econ/wallet.rs +67 -0
  20. package/crates/ckx-sdk/src/kit/mod.rs +72 -3
  21. package/crates/ckx-sdk/src/kit/play/abilities.rs +370 -0
  22. package/crates/ckx-sdk/src/kit/play/cards.rs +220 -0
  23. package/crates/ckx-sdk/src/kit/play/combat.rs +292 -0
  24. package/crates/ckx-sdk/src/kit/play/mod.rs +15 -0
  25. package/crates/ckx-sdk/src/kit/play/movement.rs +196 -0
  26. package/crates/ckx-sdk/src/kit/play/score.rs +183 -0
  27. package/crates/ckx-sdk/src/kit/play/timing.rs +218 -0
  28. package/crates/ckx-sdk/src/kit/play/turns.rs +315 -0
  29. package/crates/ckx-sdk/src/kit/presence.rs +142 -0
  30. package/crates/ckx-sdk/src/kit/rng.rs +10 -0
  31. package/crates/ckx-sdk/src/kit/sim/daycycle.rs +71 -0
  32. package/crates/ckx-sdk/src/kit/sim/mod.rs +10 -0
  33. package/crates/ckx-sdk/src/kit/sim/nodes.rs +109 -0
  34. package/crates/ckx-sdk/src/kit/sim/waves.rs +105 -0
  35. package/crates/ckx-sdk/src/kit/sim/weather.rs +142 -0
  36. package/crates/ckx-sdk/src/kit/sim/zones.rs +218 -0
  37. package/crates/ckx-sdk/src/lib.rs +2 -1
  38. package/dist/cli/src/new.js +11 -0
  39. package/kit.pin +4 -4
  40. package/package.json +1 -1
  41. package/starters/arena/Cargo.toml +14 -0
  42. package/starters/arena/src/lib.rs +849 -0
  43. package/starters/ckx.json +14 -1
  44. package/starters/deck/Cargo.toml +14 -0
  45. package/starters/deck/src/lib.rs +510 -0
  46. package/starters/director/Cargo.toml +14 -0
  47. package/starters/director/src/lib.rs +628 -0
  48. package/starters/grid-mod/Cargo.toml +1 -1
  49. package/starters/instance/Cargo.toml +14 -0
  50. package/starters/instance/src/lib.rs +656 -0
  51. package/starters/leaderboard/Cargo.toml +14 -0
  52. package/starters/leaderboard/src/lib.rs +395 -0
  53. package/starters/market/Cargo.toml +14 -0
  54. package/starters/market/src/lib.rs +1137 -0
  55. package/starters/matchmaker/Cargo.toml +1 -1
  56. package/starters/minigame/Cargo.toml +14 -0
  57. package/starters/minigame/src/lib.rs +391 -0
  58. package/starters/npc-mobs/Cargo.toml +1 -1
  59. package/starters/player/Cargo.toml +14 -0
  60. package/starters/player/src/lib.rs +994 -0
  61. package/starters/racing/Cargo.toml +14 -0
  62. package/starters/racing/src/lib.rs +1151 -0
  63. package/starters/session/Cargo.toml +1 -1
  64. package/starters/tactics/Cargo.toml +14 -0
  65. package/starters/tactics/src/lib.rs +562 -0
  66. package/starters/territory/Cargo.toml +14 -0
  67. package/starters/territory/src/lib.rs +878 -0
  68. package/starters/world-tick/Cargo.toml +1 -1
@@ -0,0 +1,656 @@
1
+ //! A run, keyed by its id: one party enters, plays down floors the hub generates from the run's
2
+ //! seed, and the run ends cleared, wiped, abandoned or expired. The dungeon and roguelike shape:
3
+ //! a floor's rooms come from `ckx_sdk::kit::rng` seeded by the run and the floor, so a seed
4
+ //! replays its layout; the hub resolves each room itself (a fight from the party's strength and
5
+ //! a seeded roll, a treasure, a rest; replace [`Run::resolve`] with your combat, or play the
6
+ //! room out in an `arena` or a `session`); a party with nobody standing is wiped and the run is
7
+ //! over (permadeath); a run nobody touches for `idle_ms` expires. Only its party plays it.
8
+ //!
9
+ //! With a `volume` in the config, opening a run reserves a grid of the world for it (scope
10
+ //! `grids.write`) and grants the party's members the config's permission keys on it (scope
11
+ //! `permissions.write`); the run's end deletes the grid. With a `board`, a run that is cleared
12
+ //! or wiped posts each member's depth to that `leaderboard` (the manifest lets `instance` call
13
+ //! `leaderboard`).
14
+ //!
15
+ //! Endpoints: `open { seed }` (a player, whose run it is; or a developer, for `{ party }`),
16
+ //! `invite { player }` and `descend` (the owner), `join` (an invited player), `enter` (a member
17
+ //! standing: resolves the room the party is at), `abandon` (the owner, or a developer), `state`
18
+ //! (anyone). It speaks the kit protocol (`ckx_sdk::kit`): the `run` topic carries the run as
19
+ //! `snapshot` messages; `snapshot { topic: "run" }` answers it; changes may carry an
20
+ //! `action_id`; refusals carry `kit:<code>:` codes.
21
+
22
+ use ckx_sdk::kit::rng::Rng;
23
+ use ckx_sdk::kit::{self, Publisher, Receipts, codes};
24
+ use ckx_sdk::prelude::*;
25
+ use serde_json::json;
26
+
27
+ pub const TOPIC: &str = "run";
28
+ const IDLE_TIMER: &str = "idle";
29
+ const IDLE_CHECK_MS: u64 = 60_000;
30
+ /// Entries the run's log keeps.
31
+ const LOG_MAX: usize = 20;
32
+
33
+ /// Where runs reserve their grids: `slots` volumes of `size` chunks, `stride` chunks apart
34
+ /// along x from `origin`. A run takes the slot its key hashes to.
35
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
36
+ pub struct Volume {
37
+ pub origin: (i64, i64, i64),
38
+ pub size: (i64, i64, i64),
39
+ pub slots: u32,
40
+ pub stride: i64,
41
+ }
42
+
43
+ impl Volume {
44
+ pub fn slot_of(&self, key: &str) -> u32 {
45
+ (Rng::seed_from(key) % u64::from(self.slots.max(1))) as u32
46
+ }
47
+
48
+ /// The chunks slot `slot` covers, both corners included.
49
+ pub fn bounds(&self, slot: u32) -> (ChunkPos, ChunkPos) {
50
+ let x = self.origin.0 + i64::from(slot) * self.stride;
51
+ let low = ChunkPos::new(x, self.origin.1, self.origin.2);
52
+ let high =
53
+ ChunkPos::new(x + self.size.0.max(1) - 1, self.origin.1 + self.size.1.max(1) - 1, self.origin.2 + self.size.2.max(1) - 1);
54
+ (low, high)
55
+ }
56
+ }
57
+
58
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
59
+ #[serde(default)]
60
+ pub struct Config {
61
+ pub floors: u32,
62
+ pub rooms_per_floor: u32,
63
+ pub party_max: usize,
64
+ pub idle_ms: u64,
65
+ pub start_hp: i64,
66
+ pub start_power: i64,
67
+ /// The `leaderboard` key cleared and wiped runs post their depth to.
68
+ pub board: Option<String>,
69
+ pub volume: Option<Volume>,
70
+ /// The permission keys members get on the run's grid.
71
+ pub member_permissions: Vec<String>,
72
+ }
73
+
74
+ impl Default for Config {
75
+ fn default() -> Self {
76
+ Self {
77
+ floors: 3,
78
+ rooms_per_floor: 4,
79
+ party_max: 4,
80
+ idle_ms: 30 * 60_000,
81
+ start_hp: 30,
82
+ start_power: 6,
83
+ board: None,
84
+ volume: None,
85
+ member_permissions: vec!["enter".into(), "build".into()],
86
+ }
87
+ }
88
+ }
89
+
90
+ #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
91
+ #[serde(rename_all = "snake_case")]
92
+ pub enum RoomKind {
93
+ Fight,
94
+ Treasure,
95
+ Rest,
96
+ Boss,
97
+ }
98
+
99
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
100
+ pub struct Room {
101
+ pub kind: RoomKind,
102
+ /// What a fight here takes.
103
+ pub power: i64,
104
+ pub done: bool,
105
+ }
106
+
107
+ #[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
108
+ #[serde(rename_all = "snake_case")]
109
+ pub enum Phase {
110
+ #[default]
111
+ Unopened,
112
+ Running,
113
+ Cleared,
114
+ Wiped,
115
+ Abandoned,
116
+ Expired,
117
+ }
118
+
119
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
120
+ pub struct Member {
121
+ pub player: u64,
122
+ pub hp: i64,
123
+ pub max_hp: i64,
124
+ pub power: i64,
125
+ }
126
+
127
+ /// The run as everyone sees it.
128
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
129
+ pub struct RunView {
130
+ pub phase: Phase,
131
+ pub owner: Option<u64>,
132
+ pub seed: u64,
133
+ pub floor: u32,
134
+ pub floors: u32,
135
+ pub room: usize,
136
+ pub rooms: Vec<Room>,
137
+ pub members: Vec<Member>,
138
+ pub invited: Vec<u64>,
139
+ pub gold: i64,
140
+ pub relics: Vec<String>,
141
+ pub grid: Option<u64>,
142
+ pub log: Vec<String>,
143
+ }
144
+
145
+ #[derive(Deserialize, Default)]
146
+ #[serde(default)]
147
+ struct Open {
148
+ seed: Option<u64>,
149
+ party: Vec<u64>,
150
+ }
151
+
152
+ #[derive(Deserialize)]
153
+ struct Invite {
154
+ player: u64,
155
+ }
156
+
157
+ /// The rooms of `floor` in a run seeded `seed`; the last floor ends with its boss.
158
+ pub fn floor_rooms(seed: u64, floor: u32, count: u32, last: bool) -> Vec<Room> {
159
+ let mut rng = Rng::seeded(seed ^ u64::from(floor).wrapping_mul(0x9e37_79b9_7f4a_7c15));
160
+ let base = i64::from(floor) * 8;
161
+ let mut rooms: Vec<Room> = (0..count.max(1))
162
+ .map(|_| {
163
+ let kind = match rng.weighted(&[5, 2, 1]) {
164
+ Some(1) => RoomKind::Treasure,
165
+ Some(2) => RoomKind::Rest,
166
+ _ => RoomKind::Fight,
167
+ };
168
+ Room { kind, power: base + rng.range(0, 6), done: false }
169
+ })
170
+ .collect();
171
+ if last && let Some(end) = rooms.last_mut() {
172
+ *end = Room { kind: RoomKind::Boss, power: base * 3, done: false };
173
+ }
174
+ rooms
175
+ }
176
+
177
+ const RELICS: [&str; 4] = ["ember charm", "frost sigil", "old map", "lucky coin"];
178
+
179
+ #[derive(Serialize, Deserialize, Default, Debug)]
180
+ pub struct Run {
181
+ pub config: Config,
182
+ pub phase: Phase,
183
+ pub owner: Option<u64>,
184
+ pub seed: u64,
185
+ pub floor: u32,
186
+ pub room: usize,
187
+ pub rooms: Vec<Room>,
188
+ pub members: Vec<Member>,
189
+ pub invited: Vec<u64>,
190
+ pub gold: i64,
191
+ pub relics: Vec<String>,
192
+ pub rng: Rng,
193
+ pub grid: Option<u64>,
194
+ pub log: Vec<String>,
195
+ pub last_touch: u64,
196
+ }
197
+
198
+ impl Run {
199
+ pub fn view(&self) -> RunView {
200
+ RunView {
201
+ phase: self.phase,
202
+ owner: self.owner,
203
+ seed: self.seed,
204
+ floor: self.floor,
205
+ floors: self.config.floors,
206
+ room: self.room,
207
+ rooms: self.rooms.clone(),
208
+ members: self.members.clone(),
209
+ invited: self.invited.clone(),
210
+ gold: self.gold,
211
+ relics: self.relics.clone(),
212
+ grid: self.grid,
213
+ log: self.log.clone(),
214
+ }
215
+ }
216
+
217
+ fn note(&mut self, line: String) {
218
+ self.log.push(line);
219
+ if self.log.len() > LOG_MAX {
220
+ self.log.remove(0);
221
+ }
222
+ }
223
+
224
+ fn member(&self, player: u64) -> Result<&Member> {
225
+ self.members.iter().find(|m| m.player == player).ok_or_else(|| kit::error(codes::NOT_MEMBER, "you are not in this run's party"))
226
+ }
227
+
228
+ fn running(&self) -> Result<()> {
229
+ match self.phase {
230
+ Phase::Running => Ok(()),
231
+ Phase::Unopened => Err(kit::error(codes::NOT_READY, "the run is not open")),
232
+ _ => Err(kit::error(codes::FINISHED, "the run is over")),
233
+ }
234
+ }
235
+
236
+ fn new_member(&self, player: u64) -> Member {
237
+ Member { player, hp: self.config.start_hp, max_hp: self.config.start_hp, power: self.config.start_power }
238
+ }
239
+
240
+ pub fn open(&mut self, party: &[u64], seed: u64, now: u64) -> Result<()> {
241
+ if self.phase != Phase::Unopened {
242
+ return Err(kit::error(codes::STARTED, "this run was opened already"));
243
+ }
244
+ if party.is_empty() || party.len() > self.config.party_max {
245
+ return Err(kit::error(codes::BAD_REQUEST, format!("a party is 1 to {} players", self.config.party_max)));
246
+ }
247
+ self.phase = Phase::Running;
248
+ self.owner = party.first().copied();
249
+ self.members = party.iter().map(|&p| self.new_member(p)).collect();
250
+ self.seed = seed;
251
+ self.rng = Rng::seeded(seed);
252
+ self.floor = 1;
253
+ self.room = 0;
254
+ self.rooms = floor_rooms(seed, 1, self.config.rooms_per_floor, self.config.floors <= 1);
255
+ self.last_touch = now;
256
+ self.note(format!("the run opened with seed {seed}"));
257
+ Ok(())
258
+ }
259
+
260
+ pub fn invite(&mut self, by: u64, player: u64) -> Result<()> {
261
+ self.running()?;
262
+ if self.owner != Some(by) {
263
+ return Err(kit::error(codes::FORBIDDEN, "the run's owner invites"));
264
+ }
265
+ if self.members.len() + self.invited.len() >= self.config.party_max {
266
+ return Err(kit::error(codes::FULL, "the party is full"));
267
+ }
268
+ if !self.invited.contains(&player) && self.members.iter().all(|m| m.player != player) {
269
+ self.invited.push(player);
270
+ }
271
+ Ok(())
272
+ }
273
+
274
+ pub fn join(&mut self, player: u64) -> Result<()> {
275
+ self.running()?;
276
+ if self.members.iter().any(|m| m.player == player) {
277
+ return Ok(());
278
+ }
279
+ let Some(i) = self.invited.iter().position(|&p| p == player) else {
280
+ return Err(kit::error(codes::FORBIDDEN, "only an invited player joins this run"));
281
+ };
282
+ self.invited.remove(i);
283
+ self.members.push(self.new_member(player));
284
+ self.note(format!("player {player} joined"));
285
+ Ok(())
286
+ }
287
+
288
+ /// Resolves the room the party is at. Returns what happened, as the log has it.
289
+ pub fn resolve(&mut self, player: u64, now: u64) -> Result<String> {
290
+ self.running()?;
291
+ if self.member(player)?.hp <= 0 {
292
+ return Err(kit::error(codes::FORBIDDEN, "you are down"));
293
+ }
294
+ let Some(room) = self.rooms.get(self.room).cloned() else {
295
+ return Err(kit::error(codes::CONFLICT, "this floor is cleared; the owner descends"));
296
+ };
297
+ self.last_touch = now;
298
+ let standing: Vec<usize> = (0..self.members.len()).filter(|&i| self.members[i].hp > 0).collect();
299
+ let n = standing.len().max(1) as i64;
300
+ let line = match room.kind {
301
+ RoomKind::Treasure => {
302
+ let gold = i64::from(self.floor) * self.rng.range(5, 15);
303
+ self.gold += gold;
304
+ let mut line = format!("treasure: {gold} gold");
305
+ if self.rng.chance(0.3)
306
+ && let Some(relic) = self.rng.pick(&RELICS)
307
+ {
308
+ self.relics.push((*relic).to_string());
309
+ line.push_str(&format!(" and the {relic}"));
310
+ }
311
+ self.rooms[self.room].done = true;
312
+ line
313
+ }
314
+ RoomKind::Rest => {
315
+ for &i in &standing {
316
+ let m = &mut self.members[i];
317
+ m.hp = (m.hp + m.max_hp / 2).min(m.max_hp);
318
+ }
319
+ self.rooms[self.room].done = true;
320
+ "a rest: the party heals".to_string()
321
+ }
322
+ RoomKind::Fight | RoomKind::Boss => {
323
+ let strength: i64 = standing.iter().map(|&i| self.members[i].power).sum();
324
+ let roll = self.rng.range(0, strength);
325
+ let won = roll + strength / 2 >= room.power;
326
+ let hurt = if won { (room.power + 2 * n - 1) / (2 * n) } else { (room.power + n - 1) / n };
327
+ for &i in &standing {
328
+ let m = &mut self.members[i];
329
+ m.hp = (m.hp - hurt).max(0);
330
+ if won && m.hp > 0 {
331
+ m.power += 1;
332
+ }
333
+ }
334
+ if won {
335
+ self.rooms[self.room].done = true;
336
+ let gold = room.power / 2;
337
+ self.gold += gold;
338
+ format!(
339
+ "{}: won ({roll} + {} against {}), {hurt} damage each, {gold} gold",
340
+ kind_name(room.kind),
341
+ strength / 2,
342
+ room.power
343
+ )
344
+ } else {
345
+ format!("{}: lost ({roll} + {} against {}), {hurt} damage each", kind_name(room.kind), strength / 2, room.power)
346
+ }
347
+ }
348
+ };
349
+ self.note(line.clone());
350
+ if self.members.iter().all(|m| m.hp <= 0) {
351
+ self.phase = Phase::Wiped;
352
+ self.note("the party fell".into());
353
+ } else if self.rooms[self.room].done {
354
+ self.room += 1;
355
+ if self.room == self.rooms.len() && self.floor >= self.config.floors {
356
+ self.phase = Phase::Cleared;
357
+ self.note("the run is cleared".into());
358
+ }
359
+ }
360
+ Ok(line)
361
+ }
362
+
363
+ pub fn descend(&mut self, by: u64, now: u64) -> Result<u32> {
364
+ self.running()?;
365
+ if self.owner != Some(by) {
366
+ return Err(kit::error(codes::FORBIDDEN, "the run's owner leads the way down"));
367
+ }
368
+ if self.room < self.rooms.len() {
369
+ return Err(kit::error(codes::NOT_READY, "this floor has rooms left"));
370
+ }
371
+ self.floor += 1;
372
+ self.room = 0;
373
+ self.rooms = floor_rooms(self.seed, self.floor, self.config.rooms_per_floor, self.floor >= self.config.floors);
374
+ self.last_touch = now;
375
+ self.note(format!("down to floor {}", self.floor));
376
+ Ok(self.floor)
377
+ }
378
+
379
+ pub fn end(&mut self, phase: Phase) {
380
+ if self.phase == Phase::Running {
381
+ self.phase = phase;
382
+ self.note(format!("the run {}", if phase == Phase::Expired { "expired" } else { "was abandoned" }));
383
+ }
384
+ }
385
+
386
+ /// Each member's depth: floors reached and rooms done, as one score.
387
+ pub fn depth(&self) -> i64 {
388
+ i64::from(self.floor) * 100 + self.room as i64
389
+ }
390
+ }
391
+
392
+ fn kind_name(kind: RoomKind) -> &'static str {
393
+ match kind {
394
+ RoomKind::Fight => "a fight",
395
+ RoomKind::Boss => "the boss",
396
+ RoomKind::Treasure => "treasure",
397
+ RoomKind::Rest => "a rest",
398
+ }
399
+ }
400
+
401
+ /// The hub: the run, and what the kit protocol keeps beside it.
402
+ #[derive(Serialize, Deserialize, Default, Debug)]
403
+ pub struct RunHub {
404
+ #[serde(flatten)]
405
+ pub run: Run,
406
+ #[serde(default)]
407
+ pub kit: Publisher,
408
+ #[serde(default)]
409
+ pub receipts: Receipts,
410
+ }
411
+
412
+ impl RunHub {
413
+ fn act(&mut self, ctx: &Ctx, call: &Call<'_>, now: u64) -> Result<Vec<u8>> {
414
+ let out = match call.method {
415
+ "open" => {
416
+ let o: Open = kit::args_or_default(call)?;
417
+ let party = match call.caller {
418
+ Caller::Player(p) => vec![p],
419
+ Caller::Developer(_) => o.party,
420
+ _ => return Err(kit::error(codes::FORBIDDEN, "a player or a developer opens a run")),
421
+ };
422
+ let seed = o.seed.unwrap_or_else(|| Rng::seed_from(&ctx.key) ^ now);
423
+ self.run.open(&party, seed, now)?;
424
+ self.reserve(ctx, &party)?;
425
+ ctx.timer_every(IDLE_TIMER, IDLE_CHECK_MS)?;
426
+ encode(&self.run.seed)
427
+ }
428
+ "invite" => {
429
+ let i: Invite = kit::args(call)?;
430
+ self.run.invite(call.player()?, i.player)?;
431
+ encode(&true)
432
+ }
433
+ "join" => {
434
+ let player = call.player()?;
435
+ self.run.join(player)?;
436
+ self.grant(ctx, player)?;
437
+ encode(&true)
438
+ }
439
+ "enter" => encode(&self.run.resolve(call.player()?, now)?),
440
+ "descend" => encode(&self.run.descend(call.player()?, now)?),
441
+ "abandon" => {
442
+ let allowed = match call.caller {
443
+ Caller::Developer(_) => true,
444
+ Caller::Player(p) => self.run.owner == Some(p),
445
+ _ => false,
446
+ };
447
+ if !allowed {
448
+ return Err(kit::error(codes::FORBIDDEN, "the run's owner, or a developer, abandons it"));
449
+ }
450
+ self.run.running()?;
451
+ self.run.end(Phase::Abandoned);
452
+ encode(&true)
453
+ }
454
+ other => return Err(Error::unknown_method(other)),
455
+ };
456
+ self.after(ctx)?;
457
+ out
458
+ }
459
+
460
+ /// Reserves the run's grid and grants the party on it, when the config has a volume.
461
+ fn reserve(&mut self, ctx: &Ctx, party: &[u64]) -> Result<()> {
462
+ let Some(volume) = self.run.config.volume.clone() else { return Ok(()) };
463
+ let slot = volume.slot_of(&ctx.key);
464
+ let (low, high) = volume.bounds(slot);
465
+ let grid = ctx.grids().create(low, high).map_err(|e| {
466
+ self.run.phase = Phase::Unopened;
467
+ kit::error(codes::CONFLICT, format!("the run's volume (slot {slot}) could not be reserved: {}", e.message))
468
+ })?;
469
+ self.run.grid = Some(grid.id);
470
+ for &p in party {
471
+ self.grant(ctx, p)?;
472
+ }
473
+ Ok(())
474
+ }
475
+
476
+ fn grant(&self, ctx: &Ctx, player: u64) -> Result<()> {
477
+ if let Some(grid) = self.run.grid {
478
+ let keys: Vec<&str> = self.run.config.member_permissions.iter().map(String::as_str).collect();
479
+ ctx.permissions()
480
+ .grant(grid, player, &keys, None)
481
+ .map_err(|e| kit::error(codes::CONFLICT, format!("granting the run's grid: {}", e.message)))?;
482
+ }
483
+ Ok(())
484
+ }
485
+
486
+ /// Once the run is over: the grid goes, the board hears of it, the idle timer stops.
487
+ fn after(&mut self, ctx: &Ctx) -> Result<()> {
488
+ if !matches!(self.run.phase, Phase::Running | Phase::Unopened) {
489
+ ctx.cancel_timer(IDLE_TIMER);
490
+ if let Some(grid) = self.run.grid.take()
491
+ && let Err(e) = ctx.grids().delete(grid)
492
+ {
493
+ ctx.log(Level::Warn, &format!("the run's grid {grid} was not deleted: {}", e.message));
494
+ }
495
+ if let (Some(board), Phase::Cleared | Phase::Wiped) = (&self.run.config.board, self.run.phase) {
496
+ for m in &self.run.members {
497
+ let submit = json!({ "player": m.player, "score": self.run.depth(), "action_id": format!("{}:{}", ctx.key, m.player) });
498
+ if let Err(e) = ctx.send("leaderboard", board, "submit", &encode(&submit)?) {
499
+ ctx.log(Level::Warn, &format!("the run's depth did not reach board {board}: {}", e.message));
500
+ }
501
+ }
502
+ }
503
+ ctx.persist_now();
504
+ }
505
+ self.kit.snapshot(ctx, TOPIC, &self.run.view())
506
+ }
507
+ }
508
+
509
+ impl Hub for RunHub {
510
+ fn spawn(_ctx: &Ctx, seed: &[u8]) -> Result<Self> {
511
+ let mut hub = Self::default();
512
+ if !seed.is_empty() {
513
+ hub.run.config = serde_json::from_slice(seed).map_err(|e| Error::new(format!("seed is not a JSON config: {e}")))?;
514
+ }
515
+ Ok(hub)
516
+ }
517
+
518
+ fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
519
+ decode(snapshot)
520
+ }
521
+
522
+ fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
523
+ encode(self)
524
+ }
525
+
526
+ fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
527
+ let now = ctx.now_ms();
528
+ match call.method {
529
+ "state" => encode(&self.run.view()),
530
+ kit::SNAPSHOT_METHOD => self.kit.snapshot_reply(ctx, TOPIC, &self.run.view()),
531
+ _ => Receipts::run(self, |h| &mut h.receipts, &call, now, |h| h.act(ctx, &call, now)),
532
+ }
533
+ }
534
+
535
+ fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
536
+ if timer == IDLE_TIMER
537
+ && self.run.phase == Phase::Running
538
+ && ctx.now_ms().saturating_sub(self.run.last_touch) >= self.run.config.idle_ms
539
+ {
540
+ self.run.end(Phase::Expired);
541
+ self.after(ctx)?;
542
+ }
543
+ Ok(())
544
+ }
545
+ }
546
+
547
+ ckx_sdk::export_hub!(RunHub);
548
+
549
+ #[cfg(test)]
550
+ mod tests {
551
+ use super::*;
552
+ use ckx_sdk::kit::error_code;
553
+ use ckx_sdk::testing::TestHub;
554
+
555
+ fn code(e: &CallError) -> Option<&str> {
556
+ error_code(&e.message).map(|c| c.0)
557
+ }
558
+
559
+ #[test]
560
+ fn a_seed_replays_its_floors_and_the_last_ends_with_the_boss() {
561
+ assert_eq!(floor_rooms(42, 1, 4, false), floor_rooms(42, 1, 4, false));
562
+ assert_ne!(floor_rooms(42, 1, 4, false), floor_rooms(43, 1, 4, false));
563
+ let last = floor_rooms(42, 3, 4, true);
564
+ assert_eq!(last.last().map(|r| r.kind), Some(RoomKind::Boss));
565
+ }
566
+
567
+ #[test]
568
+ fn only_the_party_plays_and_a_strong_party_clears_the_run() {
569
+ let mut hub = TestHub::<RunHub>::spawn_with(
570
+ "instance",
571
+ "run-a",
572
+ br#"{"floors":2,"rooms_per_floor":3,"start_hp":500,"start_power":40,"board":"deepest"}"#,
573
+ )
574
+ .unwrap();
575
+ let seed: u64 = hub.call_typed(Caller::Player(1), "open", &json!({ "seed": 7 })).unwrap();
576
+ assert_eq!(seed, 7);
577
+ let e = hub.call(Caller::Player(2), "join", &encode(&()).unwrap()).unwrap_err();
578
+ assert_eq!(code(&e), Some(codes::FORBIDDEN));
579
+ let e = hub.call(Caller::Player(2), "invite", &encode(&json!({ "player": 3 })).unwrap()).unwrap_err();
580
+ assert_eq!(code(&e), Some(codes::FORBIDDEN));
581
+ let _: bool = hub.call_typed(Caller::Player(1), "invite", &json!({ "player": 2 })).unwrap();
582
+ let _: bool = hub.call_typed(Caller::Player(2), "join", &()).unwrap();
583
+ let e = hub.call(Caller::Player(3), "enter", &encode(&()).unwrap()).unwrap_err();
584
+ assert_eq!(code(&e), Some(codes::NOT_MEMBER));
585
+ let mut guard = 0;
586
+ loop {
587
+ let view: RunView = hub.call_typed(Caller::Player(2), "state", &()).unwrap();
588
+ if view.phase != Phase::Running {
589
+ assert_eq!(view.phase, Phase::Cleared, "{:?}", view.log);
590
+ assert_eq!(view.floor, 2);
591
+ break;
592
+ }
593
+ if view.room == view.rooms.len() {
594
+ let e = hub.call(Caller::Player(2), "descend", &encode(&()).unwrap()).unwrap_err();
595
+ assert_eq!(code(&e), Some(codes::FORBIDDEN));
596
+ let _: u32 = hub.call_typed(Caller::Player(1), "descend", &()).unwrap();
597
+ } else {
598
+ let _: String = hub.call_typed(Caller::Player(2), "enter", &()).unwrap();
599
+ }
600
+ guard += 1;
601
+ assert!(guard < 100);
602
+ }
603
+ let sent = hub.sent();
604
+ assert_eq!(sent.len(), 2, "each member's depth goes to the board");
605
+ assert!(sent.iter().all(|m| m.node_type == "leaderboard" && m.key == "deepest" && m.method == "submit"));
606
+ let e = hub.call(Caller::Player(1), "enter", &encode(&()).unwrap()).unwrap_err();
607
+ assert_eq!(code(&e), Some(codes::FINISHED));
608
+ }
609
+
610
+ #[test]
611
+ fn a_weak_party_is_wiped_and_the_run_is_over() {
612
+ let mut run = Run { config: Config { start_hp: 5, start_power: 1, ..Config::default() }, ..Default::default() };
613
+ run.open(&[1], 3, 0).unwrap();
614
+ for _ in 0..50 {
615
+ if run.phase != Phase::Running {
616
+ break;
617
+ }
618
+ if run.room == run.rooms.len() {
619
+ run.descend(1, 0).unwrap();
620
+ } else {
621
+ run.resolve(1, 0).unwrap();
622
+ }
623
+ }
624
+ assert_eq!(run.phase, Phase::Wiped, "{:?}", run.log);
625
+ }
626
+
627
+ #[test]
628
+ fn a_run_nobody_touches_expires() {
629
+ let mut hub = TestHub::<RunHub>::spawn_with("instance", "run-b", br#"{"idle_ms":120000}"#).unwrap();
630
+ let _: u64 = hub.call_typed(Caller::Player(1), "open", &()).unwrap();
631
+ hub.advance(3 * IDLE_CHECK_MS).unwrap();
632
+ let view: RunView = hub.call_typed(Caller::Player(1), "state", &()).unwrap();
633
+ assert_eq!(view.phase, Phase::Expired);
634
+ assert!(hub.timers().is_empty());
635
+ }
636
+
637
+ #[test]
638
+ fn a_volume_is_reserved_for_the_run_granted_to_its_party_and_deleted_at_its_end() {
639
+ let mut hub =
640
+ TestHub::<RunHub>::spawn_with("instance", "run-c", br#"{"volume":{"origin":[1000,0,0],"size":[4,2,4],"slots":8,"stride":8}}"#)
641
+ .unwrap();
642
+ hub.answer_node("grids.create", |body| Ok(json!({ "grid": { "gridId": "55", "low": body["low"], "high": body["high"] } })));
643
+ hub.answer_node("permissions.grant", |_| Ok(json!({ "expiresAt": null })));
644
+ hub.answer_node("grids.delete", |_| Ok(json!({})));
645
+ let _: u64 = hub.call_typed(Caller::Player(1), "open", &()).unwrap();
646
+ let view: RunView = hub.call_typed(Caller::Player(1), "state", &()).unwrap();
647
+ assert_eq!(view.grid, Some(55));
648
+ let _: bool = hub.call_typed(Caller::Player(1), "abandon", &()).unwrap();
649
+ let ops: Vec<String> = hub.node_requests().into_iter().map(|(op, _)| op).collect();
650
+ assert_eq!(ops, vec!["grids.create", "permissions.grant", "grids.delete"]);
651
+ let create = &hub.node_requests()[0].1;
652
+ let slot = i64::from(Volume { origin: (1000, 0, 0), size: (4, 2, 4), slots: 8, stride: 8 }.slot_of("run-c"));
653
+ assert_eq!(create["low"]["x"], json!((1000 + slot * 8).to_string()));
654
+ assert_eq!(create["high"]["y"], json!("1"));
655
+ }
656
+ }
@@ -0,0 +1,14 @@
1
+ [package]
2
+ name = "leaderboard"
3
+ description = "Starter: a leaderboard the hub ranks itself, with ties, time boards, sums and seasons on a schedule"
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.12.1"
13
+ serde = { version = "1", features = ["derive"] }
14
+ serde_json = "1"