@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
|
@@ -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
|
-
//!
|
|
8
|
-
//!
|
|
9
|
-
//!
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
153
|
-
let mut
|
|
154
|
-
|
|
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 =
|
|
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(||
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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) =
|
|
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
|
-
|
|
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
|
-
|
|
429
|
+
mm.queue.extend(p.entries);
|
|
257
430
|
}
|
|
258
431
|
}
|
|
259
432
|
}
|
|
260
|
-
encode(&
|
|
433
|
+
encode(&mm.status(player, now))
|
|
261
434
|
}
|
|
262
435
|
"decline" => {
|
|
263
436
|
let player = call.player()?;
|
|
264
|
-
if let Some(id) =
|
|
265
|
-
|
|
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
|
|
272
|
-
if !(1..=64).contains(&config.players_per_match) || config.accept_ms < 1_000 {
|
|
273
|
-
return Err(
|
|
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
|
-
|
|
451
|
+
mm.config = config;
|
|
276
452
|
encode(&true)
|
|
277
453
|
}
|
|
278
454
|
other => return Err(Error::unknown_method(other)),
|
|
279
455
|
};
|
|
280
|
-
if
|
|
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
|
-
|
|
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!(
|
|
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
|
-
|
|
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"
|