@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
@@ -4,18 +4,30 @@
4
4
  //! time. When all do, the queue reserves a `session` hub for them (the manifest lets this type
5
5
  //! call `session`) and tells each player its key. Replaces the `matchmaking` template.
6
6
  //!
7
- //! Endpoints (players): `join { rating }`, `leave`, `accept`, `decline`, `status` (your own
8
- //! place: queued, proposed or matched). `configure { .. }` (developers). The `queue` topic
9
- //! carries how many are waiting, never who.
7
+ //! A party queues as one block: its leader joins with `party` (the other members' ids), each
8
+ //! member confirms by joining with `leader`, and once all have, the block waits in the queue
9
+ //! and is matched whole, or not at all.
10
+ //!
11
+ //! Endpoints (players): `join { rating, party?, leader? }`, `leave`, `accept`, `decline`,
12
+ //! `status` (your own place: forming a party, queued, proposed or matched). `configure { .. }`
13
+ //! (developers). It speaks the kit protocol (`ckx_sdk::kit`): the `queue` topic carries
14
+ //! `snapshot` messages of how many are waiting, never who, and so does `snapshot`; changes
15
+ //! may carry an `action_id`; refusals carry `kit:<code>:` codes. A client polls `status` while
16
+ //! it waits.
10
17
 
11
18
  use std::collections::{BTreeMap, BTreeSet};
12
19
 
20
+ use ckx_sdk::kit::{self, Publisher, Receipts, codes};
13
21
  use ckx_sdk::prelude::*;
14
22
 
15
23
  pub const TICK_MS: u64 = 1_000;
24
+ pub const TOPIC: &str = "queue";
16
25
  /// How long a player's match stays in `status` after it is made.
17
26
  const MATCHED_KEEP_MS: u64 = 120_000;
18
27
  const MAX_QUEUE: usize = 10_000;
28
+ /// The most players one party may hold.
29
+ pub const MAX_PARTY: usize = 8;
30
+ const MAX_FORMING: usize = 2_000;
19
31
 
20
32
  #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
21
33
  #[serde(default)]
@@ -26,11 +38,13 @@ pub struct Config {
26
38
  pub widen_per_sec: u32,
27
39
  pub max_window: u32,
28
40
  pub accept_ms: u64,
41
+ /// How long a party has for its members to confirm.
42
+ pub party_ms: u64,
29
43
  }
30
44
 
31
45
  impl Default for Config {
32
46
  fn default() -> Self {
33
- Self { players_per_match: 2, window: 100, widen_per_sec: 10, max_window: 500, accept_ms: 15_000 }
47
+ Self { players_per_match: 2, window: 100, widen_per_sec: 10, max_window: 500, accept_ms: 15_000, party_ms: 60_000 }
34
48
  }
35
49
  }
36
50
 
@@ -39,6 +53,26 @@ pub struct Entry {
39
53
  pub player: u64,
40
54
  pub rating: u32,
41
55
  pub since: u64,
56
+ /// The leader of the party this entry queues with.
57
+ #[serde(default)]
58
+ pub party: Option<u64>,
59
+ }
60
+
61
+ impl Entry {
62
+ /// The block it is matched in: its party's leader, or the player alone.
63
+ fn block(&self) -> u64 {
64
+ self.party.unwrap_or(self.player)
65
+ }
66
+ }
67
+
68
+ /// A party its members are still confirming.
69
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
70
+ pub struct Party {
71
+ pub leader: u64,
72
+ pub members: Vec<u64>,
73
+ /// Each confirmed member's rating; the leader's from the start.
74
+ pub ratings: BTreeMap<u64, u32>,
75
+ pub since: u64,
42
76
  }
43
77
 
44
78
  #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
@@ -69,14 +103,45 @@ pub struct Matched {
69
103
  #[serde(tag = "state", rename_all = "snake_case")]
70
104
  pub enum Place {
71
105
  Idle,
72
- Queued { rating: u32, waited_ms: u64, window: u32 },
73
- Proposed { players: Vec<u64>, accepted: Vec<u64>, expires_in_ms: u64 },
74
- Matched { session: String },
106
+ /// In a party its members are confirming.
107
+ Party {
108
+ leader: u64,
109
+ members: Vec<u64>,
110
+ confirmed: Vec<u64>,
111
+ expires_in_ms: u64,
112
+ },
113
+ Queued {
114
+ rating: u32,
115
+ waited_ms: u64,
116
+ window: u32,
117
+ },
118
+ Proposed {
119
+ players: Vec<u64>,
120
+ accepted: Vec<u64>,
121
+ expires_in_ms: u64,
122
+ },
123
+ Matched {
124
+ session: String,
125
+ },
126
+ }
127
+
128
+ /// What the `queue` topic carries: how many, never who.
129
+ #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Default)]
130
+ pub struct Counts {
131
+ pub waiting: usize,
132
+ pub proposals: usize,
133
+ pub forming: usize,
75
134
  }
76
135
 
77
136
  #[derive(Deserialize)]
78
137
  struct Join {
79
138
  rating: u32,
139
+ /// The other members, when the caller leads a party.
140
+ #[serde(default)]
141
+ party: Vec<u64>,
142
+ /// The party's leader, when the caller confirms a place in one.
143
+ #[serde(default)]
144
+ leader: Option<u64>,
80
145
  }
81
146
 
82
147
  #[derive(Serialize)]
@@ -91,6 +156,9 @@ pub struct Matchmaker {
91
156
  pub proposals: BTreeMap<u64, Proposal>,
92
157
  pub matched: BTreeMap<u64, Matched>,
93
158
  pub next_id: u64,
159
+ /// Parties being confirmed, by leader.
160
+ #[serde(default)]
161
+ pub forming: BTreeMap<u64, Party>,
94
162
  }
95
163
 
96
164
  impl Matchmaker {
@@ -103,67 +171,160 @@ impl Matchmaker {
103
171
  self.proposals.values().find(|p| p.has(player)).map(|p| p.id)
104
172
  }
105
173
 
174
+ fn forming_of(&self, player: u64) -> Option<u64> {
175
+ self.forming.values().find(|p| p.members.contains(&player)).map(|p| p.leader)
176
+ }
177
+
178
+ pub fn counts(&self) -> Counts {
179
+ Counts { waiting: self.queue.len(), proposals: self.proposals.len(), forming: self.forming.len() }
180
+ }
181
+
182
+ /// Takes the player out of any forming party; the party goes with them.
183
+ fn drop_forming(&mut self, player: u64) {
184
+ if let Some(leader) = self.forming_of(player) {
185
+ self.forming.remove(&leader);
186
+ }
187
+ }
188
+
189
+ /// Takes the player's block out of the queue: a party leaves with any one member.
190
+ fn drop_queued(&mut self, player: u64) {
191
+ if let Some(block) = self.queue.iter().find(|e| e.player == player).map(Entry::block) {
192
+ self.queue.retain(|e| e.block() != block);
193
+ }
194
+ }
195
+
106
196
  pub fn join(&mut self, player: u64, rating: u32, now: u64) -> Result<()> {
197
+ self.join_with(player, rating, &[], None, now)
198
+ }
199
+
200
+ /// Joins alone, leads a party (`party`: the other members), or confirms a place in the
201
+ /// party `leader` leads.
202
+ pub fn join_with(&mut self, player: u64, rating: u32, party: &[u64], leader: Option<u64>, now: u64) -> Result<()> {
107
203
  if self.proposal_of(player).is_some() {
108
- return Err(Error::new("you have a match to accept"));
204
+ return Err(kit::error(codes::CONFLICT, "you have a match to accept"));
109
205
  }
110
206
  self.matched.remove(&player);
207
+ if let Some(leader) = leader.filter(|l| *l != player) {
208
+ let p = self.forming.get_mut(&leader).ok_or_else(|| kit::error(codes::NOT_FOUND, "no party is forming under that leader"))?;
209
+ if !p.members.contains(&player) {
210
+ return Err(kit::error(codes::FORBIDDEN, "you are not in that party"));
211
+ }
212
+ p.ratings.insert(player, rating);
213
+ if p.ratings.len() == p.members.len() {
214
+ let p = self.forming.remove(&leader).expect("found above");
215
+ for m in &p.members {
216
+ self.drop_queued(*m);
217
+ }
218
+ for m in &p.members {
219
+ self.queue.push(Entry { player: *m, rating: p.ratings[m], since: now, party: Some(leader) });
220
+ }
221
+ }
222
+ return Ok(());
223
+ }
224
+ if !party.is_empty() {
225
+ let mut members = vec![player];
226
+ for m in party {
227
+ if !members.contains(m) {
228
+ members.push(*m);
229
+ }
230
+ }
231
+ let max = self.config.players_per_match.min(MAX_PARTY);
232
+ if members.len() < 2 || members.len() > max {
233
+ return Err(kit::error(codes::LIMIT, format!("a party is 2 to {max} players")));
234
+ }
235
+ if members.iter().any(|m| self.proposal_of(*m).is_some()) {
236
+ return Err(kit::error(codes::CONFLICT, "a member has a match to accept"));
237
+ }
238
+ if !self.forming.contains_key(&player) && self.forming.len() >= MAX_FORMING {
239
+ return Err(kit::error(codes::FULL, "too many parties are forming"));
240
+ }
241
+ self.drop_forming(player);
242
+ self.drop_queued(player);
243
+ self.forming.insert(player, Party { leader: player, members, ratings: BTreeMap::from([(player, rating)]), since: now });
244
+ return Ok(());
245
+ }
246
+ self.drop_forming(player);
111
247
  if let Some(e) = self.queue.iter_mut().find(|e| e.player == player) {
112
248
  e.rating = rating;
113
249
  return Ok(());
114
250
  }
115
251
  if self.queue.len() >= MAX_QUEUE {
116
- return Err(Error::new("the queue is full"));
252
+ return Err(kit::error(codes::FULL, "the queue is full"));
117
253
  }
118
- self.queue.push(Entry { player, rating, since: now });
254
+ self.queue.push(Entry { player, rating, since: now, party: None });
119
255
  Ok(())
120
256
  }
121
257
 
122
258
  pub fn leave(&mut self, player: u64) {
123
- self.queue.retain(|e| e.player != player);
259
+ self.drop_forming(player);
260
+ self.drop_queued(player);
124
261
  if let Some(id) = self.proposal_of(player) {
125
262
  self.decline(player, id);
126
263
  }
127
264
  }
128
265
 
129
- /// Proposes matches from the queue, oldest entries first.
266
+ /// Proposes matches from the queue, oldest blocks first. A block (a player, or a party)
267
+ /// joins a match whole, or waits.
130
268
  pub fn propose(&mut self, now: u64) -> Vec<u64> {
131
269
  let n = self.config.players_per_match.max(1);
132
270
  let mut waiting = std::mem::take(&mut self.queue);
133
271
  waiting.sort_by_key(|e| e.since);
272
+ let mut order: Vec<u64> = Vec::new();
273
+ let mut blocks: BTreeMap<u64, Vec<Entry>> = BTreeMap::new();
274
+ for e in waiting {
275
+ let b = e.block();
276
+ if !blocks.contains_key(&b) {
277
+ order.push(b);
278
+ }
279
+ blocks.entry(b).or_default().push(e);
280
+ }
281
+ // A block's rating is its members' mean; its window is its narrowest.
282
+ let stats: BTreeMap<u64, (u32, u32, usize)> = blocks
283
+ .iter()
284
+ .map(|(b, es)| {
285
+ let rating = (es.iter().map(|e| u64::from(e.rating)).sum::<u64>() / es.len().max(1) as u64) as u32;
286
+ let window = es.iter().map(|e| self.window(e, now)).min().unwrap_or(0);
287
+ (*b, (rating, window, es.len()))
288
+ })
289
+ .collect();
134
290
  let mut made = Vec::new();
135
- let mut i = 0;
136
- while i < waiting.len() {
137
- let a = waiting[i].clone();
138
- let picked: Vec<usize> = std::iter::once(i)
139
- .chain(
140
- (i + 1..waiting.len())
141
- .filter(|&j| {
142
- let b = &waiting[j];
143
- a.rating.abs_diff(b.rating) <= self.window(&a, now).min(self.window(b, now))
144
- })
145
- .take(n - 1),
146
- )
147
- .collect();
148
- if picked.len() < n {
149
- i += 1;
291
+ let mut used: BTreeSet<u64> = BTreeSet::new();
292
+ for (i, a) in order.iter().enumerate() {
293
+ if used.contains(a) {
150
294
  continue;
151
295
  }
152
- // Highest index first, so the lower ones stay valid; then back into queue order.
153
- let mut entries: Vec<Entry> = picked.iter().rev().map(|&j| waiting.remove(j)).collect();
154
- entries.reverse();
296
+ let (ra, wa, mut size) = stats[a];
297
+ let mut picked = vec![*a];
298
+ for b in &order[i + 1..] {
299
+ if size == n {
300
+ break;
301
+ }
302
+ let (rb, wb, len) = stats[b];
303
+ if used.contains(b) || size + len > n {
304
+ continue;
305
+ }
306
+ if ra.abs_diff(rb) <= wa.min(wb) {
307
+ picked.push(*b);
308
+ size += len;
309
+ }
310
+ }
311
+ if size != n {
312
+ continue;
313
+ }
314
+ used.extend(picked.iter().copied());
315
+ let entries: Vec<Entry> = picked.iter().flat_map(|b| blocks[b].clone()).collect();
155
316
  self.next_id += 1;
156
317
  let id = self.next_id;
157
318
  self.proposals.insert(id, Proposal { id, entries, accepted: BTreeSet::new(), expires: now + self.config.accept_ms });
158
319
  made.push(id);
159
320
  }
160
- self.queue = waiting;
321
+ self.queue = order.iter().filter(|b| !used.contains(b)).flat_map(|b| blocks.remove(b).unwrap_or_default()).collect();
161
322
  made
162
323
  }
163
324
 
164
325
  /// Returns the proposal when this acceptance completes it.
165
326
  pub fn accept(&mut self, player: u64) -> Result<Option<Proposal>> {
166
- let id = self.proposal_of(player).ok_or_else(|| Error::new("you have no match to accept"))?;
327
+ let id = self.proposal_of(player).ok_or_else(|| kit::error(codes::NOT_FOUND, "you have no match to accept"))?;
167
328
  let p = self.proposals.get_mut(&id).expect("found above");
168
329
  p.accepted.insert(player);
169
330
  if p.accepted.len() < p.entries.len() {
@@ -172,25 +333,38 @@ impl Matchmaker {
172
333
  Ok(self.proposals.remove(&id))
173
334
  }
174
335
 
175
- /// The decliner leaves the queue; the others go back, keeping their place.
336
+ /// The decliner's block leaves the queue; the others go back, keeping their place.
176
337
  pub fn decline(&mut self, player: u64, id: u64) {
177
338
  if let Some(p) = self.proposals.remove(&id) {
178
- self.queue.extend(p.entries.into_iter().filter(|e| e.player != player));
339
+ let gone = p.entries.iter().find(|e| e.player == player).map(Entry::block);
340
+ self.queue.extend(p.entries.into_iter().filter(|e| Some(e.block()) != gone));
179
341
  }
180
342
  }
181
343
 
182
- /// Proposals past their time: those who accepted go back, the others leave.
344
+ /// Proposals past their time: the blocks whose every member accepted go back, the others
345
+ /// leave. Parties past their time to confirm are dropped.
183
346
  pub fn expire(&mut self, now: u64) {
184
347
  let late: Vec<u64> = self.proposals.values().filter(|p| p.expires <= now).map(|p| p.id).collect();
185
348
  for id in late {
186
349
  if let Some(p) = self.proposals.remove(&id) {
187
- self.queue.extend(p.entries.into_iter().filter(|e| p.accepted.contains(&e.player)));
350
+ let declined: BTreeSet<u64> = p.entries.iter().filter(|e| !p.accepted.contains(&e.player)).map(Entry::block).collect();
351
+ self.queue.extend(p.entries.into_iter().filter(|e| !declined.contains(&e.block())));
188
352
  }
189
353
  }
354
+ let party_ms = self.config.party_ms;
355
+ self.forming.retain(|_, p| now.saturating_sub(p.since) < party_ms);
190
356
  self.matched.retain(|_, m| now.saturating_sub(m.at) < MATCHED_KEEP_MS);
191
357
  }
192
358
 
193
359
  pub fn status(&self, player: u64, now: u64) -> Place {
360
+ if let Some(p) = self.forming_of(player).and_then(|l| self.forming.get(&l)) {
361
+ return Place::Party {
362
+ leader: p.leader,
363
+ members: p.members.clone(),
364
+ confirmed: p.ratings.keys().copied().collect(),
365
+ expires_in_ms: (p.since + self.config.party_ms).saturating_sub(now),
366
+ };
367
+ }
194
368
  if let Some(e) = self.queue.iter().find(|e| e.player == player) {
195
369
  return Place::Queued { rating: e.rating, waited_ms: now.saturating_sub(e.since), window: self.window(e, now) };
196
370
  }
@@ -208,39 +382,38 @@ impl Matchmaker {
208
382
  }
209
383
 
210
384
  fn busy(&self) -> bool {
211
- !self.queue.is_empty() || !self.proposals.is_empty()
385
+ !self.queue.is_empty() || !self.proposals.is_empty() || !self.forming.is_empty()
212
386
  }
213
387
  }
214
388
 
215
- impl Hub for Matchmaker {
216
- fn spawn(_ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
217
- Ok(Self::default())
218
- }
219
-
220
- fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
221
- decode(snapshot)
222
- }
223
-
224
- fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
225
- encode(self)
226
- }
389
+ /// The hub: the queue, and what the kit protocol keeps beside it.
390
+ #[derive(Serialize, Deserialize, Default, Debug)]
391
+ pub struct MatchmakerHub {
392
+ #[serde(flatten)]
393
+ pub mm: Matchmaker,
394
+ #[serde(default)]
395
+ pub kit: Publisher,
396
+ #[serde(default)]
397
+ pub receipts: Receipts,
398
+ }
227
399
 
228
- fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
229
- let now = ctx.now_ms();
400
+ impl MatchmakerHub {
401
+ fn act(&mut self, ctx: &Ctx, call: &Call<'_>, now: u64) -> Result<Vec<u8>> {
402
+ let mm = &mut self.mm;
230
403
  let out = match call.method {
231
- "status" => return encode(&self.status(call.player()?, now)),
232
404
  "join" => {
233
- let req: Join = call.decode()?;
234
- self.join(call.player()?, req.rating, now)?;
235
- encode(&self.status(call.player()?, now))
405
+ let player = call.player()?;
406
+ let req: Join = kit::args(call)?;
407
+ mm.join_with(player, req.rating, &req.party, req.leader, now)?;
408
+ encode(&mm.status(player, now))
236
409
  }
237
410
  "leave" => {
238
- self.leave(call.player()?);
411
+ mm.leave(call.player()?);
239
412
  encode(&true)
240
413
  }
241
414
  "accept" => {
242
415
  let player = call.player()?;
243
- if let Some(p) = self.accept(player)? {
416
+ if let Some(p) = mm.accept(player)? {
244
417
  // The epoch rises on every placement, so a queue restarted without its
245
418
  // snapshot never hands out a key an earlier match used.
246
419
  let session = format!("{}-{}-{}", ctx.key, ctx.epoch, p.id);
@@ -248,61 +421,90 @@ impl Hub for Matchmaker {
248
421
  match ctx.call_typed::<_, bool>("session", &session, "reserve", &Reserve { players: &players }) {
249
422
  Ok(_) => {
250
423
  for player in players {
251
- self.matched.insert(player, Matched { session: session.clone(), at: now });
424
+ mm.matched.insert(player, Matched { session: session.clone(), at: now });
252
425
  }
253
426
  }
254
427
  Err(e) => {
255
428
  ctx.log(Level::Warn, &format!("reserving session {session}: {e}"));
256
- self.queue.extend(p.entries);
429
+ mm.queue.extend(p.entries);
257
430
  }
258
431
  }
259
432
  }
260
- encode(&self.status(player, now))
433
+ encode(&mm.status(player, now))
261
434
  }
262
435
  "decline" => {
263
436
  let player = call.player()?;
264
- if let Some(id) = self.proposal_of(player) {
265
- self.decline(player, id);
437
+ if let Some(id) = mm.proposal_of(player) {
438
+ mm.decline(player, id);
266
439
  }
267
440
  encode(&true)
268
441
  }
269
442
  "configure" => {
270
- call.developer()?;
271
- let config: Config = call.decode()?;
272
- if !(1..=64).contains(&config.players_per_match) || config.accept_ms < 1_000 {
273
- return Err(Error::new("1 <= players_per_match <= 64, and accept_ms is at least 1000"));
443
+ call.developer().map_err(|_| kit::error(codes::FORBIDDEN, "configure is for the app's developers"))?;
444
+ let config: Config = kit::args(call)?;
445
+ if !(1..=64).contains(&config.players_per_match) || config.accept_ms < 1_000 || config.party_ms < 1_000 {
446
+ return Err(kit::error(
447
+ codes::BAD_REQUEST,
448
+ "1 <= players_per_match <= 64, and accept_ms and party_ms are at least 1000",
449
+ ));
274
450
  }
275
- self.config = config;
451
+ mm.config = config;
276
452
  encode(&true)
277
453
  }
278
454
  other => return Err(Error::unknown_method(other)),
279
455
  };
280
- if self.busy() && !ctx.timers().iter().any(|(name, _)| name == "match") {
456
+ if mm.busy() && !ctx.timers().iter().any(|(name, _)| name == "match") {
281
457
  ctx.timer_every("match", TICK_MS)?;
282
458
  }
283
459
  out
284
460
  }
461
+ }
462
+
463
+ impl Hub for MatchmakerHub {
464
+ fn spawn(_ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
465
+ Ok(Self::default())
466
+ }
467
+
468
+ /// Reads this version's snapshots and the earlier ones, which held the queue alone.
469
+ fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
470
+ decode(snapshot)
471
+ }
472
+
473
+ fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
474
+ encode(self)
475
+ }
476
+
477
+ fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
478
+ let now = ctx.now_ms();
479
+ match call.method {
480
+ "status" => encode(&self.mm.status(call.player()?, now)),
481
+ kit::SNAPSHOT_METHOD => self.kit.snapshot_reply(ctx, TOPIC, &self.mm.counts()),
482
+ _ => Receipts::run(self, |h| &mut h.receipts, &call, now, |h| h.act(ctx, &call, now)),
483
+ }
484
+ }
285
485
 
286
486
  fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
287
487
  if timer != "match" {
288
488
  return Ok(());
289
489
  }
290
490
  let now = ctx.now_ms();
291
- self.expire(now);
292
- self.propose(now);
293
- ctx.publish("queue", &encode(&(self.queue.len(), self.proposals.len()))?);
294
- if !self.busy() {
491
+ self.mm.expire(now);
492
+ self.mm.propose(now);
493
+ self.kit.snapshot(ctx, TOPIC, &self.mm.counts())?;
494
+ if !self.mm.busy() {
295
495
  ctx.cancel_timer("match");
296
496
  }
297
497
  Ok(())
298
498
  }
299
499
  }
300
500
 
301
- ckx_sdk::export_hub!(Matchmaker);
501
+ ckx_sdk::export_hub!(MatchmakerHub);
302
502
 
303
503
  #[cfg(test)]
304
504
  mod tests {
305
505
  use super::*;
506
+ use ckx_sdk::kit::{Message, MessageKind, error_code};
507
+ use ckx_sdk::testing::TestHub;
306
508
 
307
509
  #[test]
308
510
  fn close_ratings_match_at_once_and_far_ones_after_the_window_widens() {
@@ -355,8 +557,66 @@ mod tests {
355
557
  m.join(1, 1000, 0).unwrap();
356
558
  m.join(2, 1000, 0).unwrap();
357
559
  m.propose(0);
358
- assert!(m.join(1, 900, 1).is_err());
560
+ let Error(why) = m.join(1, 900, 1).unwrap_err();
561
+ assert_eq!(error_code(&why).map(|c| c.0), Some(codes::CONFLICT));
359
562
  m.leave(1);
360
563
  assert_eq!(m.queue.iter().map(|e| e.player).collect::<Vec<_>>(), vec![2]);
361
564
  }
565
+
566
+ #[test]
567
+ fn a_party_queues_once_every_member_confirms_and_is_matched_whole() {
568
+ let mut m = Matchmaker { config: Config { players_per_match: 4, ..Config::default() }, ..Default::default() };
569
+ m.join_with(1, 1000, &[2, 3], None, 0).unwrap();
570
+ assert!(matches!(m.status(2, 0), Place::Party { leader: 1, .. }));
571
+ let Error(why) = m.join_with(9, 1000, &[], Some(1), 0).unwrap_err();
572
+ assert_eq!(error_code(&why).map(|c| c.0), Some(codes::FORBIDDEN), "not a member");
573
+ m.join_with(2, 1100, &[], Some(1), 1).unwrap();
574
+ assert!(m.queue.is_empty(), "3 has not confirmed");
575
+ m.join_with(3, 900, &[], Some(1), 2).unwrap();
576
+ assert_eq!(m.queue.len(), 3);
577
+ assert!(m.queue.iter().all(|e| e.party == Some(1)));
578
+ // Three players of a party and one alone make four; two alone do not split the party.
579
+ m.join(4, 1000, 3).unwrap();
580
+ m.join(5, 1000, 3).unwrap();
581
+ let ids = m.propose(3);
582
+ assert_eq!(ids.len(), 1);
583
+ let mut players = m.proposals[&ids[0]].players();
584
+ players.sort_unstable();
585
+ assert_eq!(players, vec![1, 2, 3, 4]);
586
+ assert_eq!(m.queue.iter().map(|e| e.player).collect::<Vec<_>>(), vec![5]);
587
+ // One member declining takes the whole party out; the solo player goes back.
588
+ m.decline(2, ids[0]);
589
+ assert_eq!(m.queue.iter().map(|e| e.player).collect::<Vec<_>>(), vec![5, 4]);
590
+ }
591
+
592
+ #[test]
593
+ fn a_party_too_large_or_left_unconfirmed_goes() {
594
+ let mut m = Matchmaker::default();
595
+ let Error(why) = m.join_with(1, 1000, &[2, 3], None, 0).unwrap_err();
596
+ assert_eq!(error_code(&why).map(|c| c.0), Some(codes::LIMIT), "two a match");
597
+ m.join_with(1, 1000, &[2], None, 0).unwrap();
598
+ m.expire(m.config.party_ms);
599
+ assert!(m.forming.is_empty());
600
+ assert_eq!(m.status(1, m.config.party_ms), Place::Idle);
601
+ }
602
+
603
+ #[test]
604
+ fn the_queue_topic_counts_and_never_names_and_old_snapshots_load() {
605
+ #[derive(Serialize)]
606
+ struct J {
607
+ rating: u32,
608
+ }
609
+ let mut hub = TestHub::<MatchmakerHub>::spawn("matchmaker", "ranked").unwrap();
610
+ let _: Place = hub.call_typed(Caller::Player(1), "join", &J { rating: 1000 }).unwrap();
611
+ hub.advance(TICK_MS).unwrap();
612
+ let (topic, bytes) = hub.published().last().cloned().unwrap();
613
+ assert_eq!(topic, TOPIC);
614
+ let msg: Message<Counts> = decode(&bytes).unwrap();
615
+ assert_eq!((msg.header.kind, msg.body.waiting), (MessageKind::Snapshot, 1));
616
+ drop(hub);
617
+ let mut old = Matchmaker::default();
618
+ old.join(7, 1200, 0).unwrap();
619
+ let hub = TestHub::<MatchmakerHub>::load("matchmaker", "ranked", &encode(&old).unwrap(), 1).unwrap();
620
+ assert_eq!(hub.hub().mm.queue.len(), 1);
621
+ }
362
622
  }
@@ -0,0 +1,14 @@
1
+ [package]
2
+ name = "minigame"
3
+ description = "Starter: a two-player duel of hidden throws the hub reveals together, best of a number of rounds, with rock, paper, scissors as its game"
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"