@crowdedkingdoms/ckx-kit 0.2.0-test.1 → 0.3.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 +9 -0
- package/crates/ckx-sdk/Cargo.toml +1 -1
- package/crates/ckx-sdk/src/kit/mod.rs +494 -0
- package/crates/ckx-sdk/src/kit/rng.rs +127 -0
- package/crates/ckx-sdk/src/kit/shard.rs +65 -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/kit.pin +4 -4
- package/package.json +1 -1
- package/starters/grid-mod/Cargo.toml +1 -1
- package/starters/matchmaker/Cargo.toml +1 -1
- package/starters/matchmaker/src/lib.rs +334 -74
- package/starters/npc-mobs/Cargo.toml +1 -1
- package/starters/npc-mobs/src/lib.rs +277 -67
- package/starters/session/Cargo.toml +1 -1
- package/starters/session/src/lib.rs +191 -86
- package/starters/world-tick/Cargo.toml +1 -1
- package/starters/world-tick/src/lib.rs +151 -38
|
@@ -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).
|
|
10
|
-
//! is
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
154
|
+
return Err(kit::error(codes::STARTED, "the session has started"));
|
|
149
155
|
}
|
|
150
156
|
if self.host != Some(player) {
|
|
151
|
-
return Err(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
214
|
-
|
|
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
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
+
game.join(call.player()?)?;
|
|
226
274
|
ctx.cancel_timer("idle");
|
|
227
275
|
encode(&true)
|
|
228
276
|
}
|
|
229
277
|
"leave" => {
|
|
230
|
-
if
|
|
231
|
-
|
|
278
|
+
if game.leave(call.player()?) {
|
|
279
|
+
game.arm_turn(ctx)?;
|
|
232
280
|
}
|
|
233
281
|
encode(&true)
|
|
234
282
|
}
|
|
235
283
|
"ready" => {
|
|
236
|
-
let
|
|
237
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
|
256
|
-
let more =
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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) =
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
349
|
+
"idle" if game.phase == Phase::Lobby && game.players.is_empty() => game.phase = Phase::Finished,
|
|
309
350
|
_ => return Ok(()),
|
|
310
351
|
}
|
|
311
|
-
|
|
312
|
-
Ok(())
|
|
352
|
+
self.kit.snapshot(ctx, TOPIC, &self.game)
|
|
313
353
|
}
|
|
314
354
|
|
|
315
|
-
fn on_session(&mut self,
|
|
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
|
-
|
|
359
|
+
game.connected.insert(player);
|
|
319
360
|
}
|
|
320
361
|
Session::Left => {
|
|
321
|
-
|
|
322
|
-
if
|
|
323
|
-
&& let Some(next) =
|
|
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
|
-
|
|
366
|
+
game.host = Some(*next);
|
|
326
367
|
}
|
|
327
368
|
}
|
|
328
369
|
}
|
|
329
|
-
|
|
370
|
+
self.kit.snapshot(ctx, TOPIC, &self.game)
|
|
330
371
|
}
|
|
331
372
|
}
|
|
332
373
|
|
|
333
|
-
ckx_sdk::export_hub!(
|
|
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
|
-
|
|
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
|
}
|