@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 CHANGED
@@ -45,6 +45,15 @@ on the platform: Crowdy Studio, or `execModBuild` then `execModDeploy` (see
45
45
  `CKX_KIT_TARGET_DIR` shares one cargo target directory between crates, so their dependencies
46
46
  compile once.
47
47
 
48
+ ## The game kit
49
+
50
+ `ckx_sdk::kit` (from `ckx-sdk` 0.10.0) is the server half of the Crowded Kingdoms game kit: the
51
+ protocol a kit hub speaks to the kit clients in CrowdyJS, CrowdyCPP and CrowdyPy (topic messages
52
+ that carry a sequence number and resync after a gap, actions a client may resend with an
53
+ `action_id` that the hub answers once, refusals with a `kit:<code>:` code), shard keys, a seeded
54
+ random generator and the realtime event registry. The starter pack crates speak it, so the kit
55
+ clients' typed modules drive them as they are.
56
+
48
57
  ## Test
49
58
 
50
59
  `cargo test` runs your crate natively against `ckx_sdk::testing` (from `ckx-sdk` 0.9.0), which
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ckx-sdk"
3
- version = "0.9.0-test.1"
3
+ version = "0.10.0-dev.1"
4
4
  edition = "2024"
5
5
  description = "Write ck-exec hubs, spokes and mods for Crowded Kingdoms in Rust, compiled to wasm32-unknown-unknown"
6
6
  license = "MIT OR Apache-2.0"
@@ -0,0 +1,494 @@
1
+ //! The game kit: the protocol kit hubs speak to their clients, and the pieces the starter
2
+ //! packs are built from.
3
+ //!
4
+ //! # The protocol
5
+ //!
6
+ //! A kit hub's topics carry [`Message`]s: MessagePack maps holding the hub's data with four
7
+ //! reserved fields beside it, `v` (the protocol version, [`PROTOCOL`]), `epoch` (the epoch of
8
+ //! the instance that published it, which changes whenever the instance is placed again: after
9
+ //! a move, a restart or a crash), `seq` (one more than the topic's last message) and `kind`:
10
+ //!
11
+ //! - `snapshot`: the topic's whole public state. It replaces what a client holds.
12
+ //! - `delta`: one change to a collection: an `id`, then `type_name` and `props`, or `removed`.
13
+ //! Deltas apply in `seq` order; a client that misses one (a gap in `seq`, or another
14
+ //! `epoch`) asks for a snapshot again.
15
+ //! - `changed`: something private changed for the players in `who` (`what` names it). They
16
+ //! read it back with a call the hub answers for its caller alone. A topic has no access
17
+ //! control of its own: anyone who may call a type may subscribe to every topic it has, so
18
+ //! nothing private is ever published.
19
+ //! - `event`: something that happened, with nothing to keep.
20
+ //!
21
+ //! Every kit hub answers [`SNAPSHOT_METHOD`] (`{ topic }`) with that topic's current state as a
22
+ //! `snapshot` message whose `seq` is the topic's last. A client subscribes first, then asks,
23
+ //! and applies what arrives after the snapshot's `seq`.
24
+ //!
25
+ //! A call that changes something may carry an `action_id` (at most [`ACTION_ID_MAX`] bytes)
26
+ //! beside its arguments. [`Receipts`] keep what each caller's actions were answered, so a call
27
+ //! sent again after an unknown outcome (a deadline passed, a link was lost) gets the first
28
+ //! answer instead of running twice.
29
+ //!
30
+ //! A refusal's message starts with a code, `kit:<code>: <text>` ([`error`], [`error_code`]),
31
+ //! which clients branch on without reading prose. [`codes`] has the common ones.
32
+ //!
33
+ //! ```ignore
34
+ //! use ckx_sdk::kit::{self, MessageKind, Publisher, Receipts};
35
+ //!
36
+ //! #[derive(Default, Serialize, Deserialize)]
37
+ //! struct Board {
38
+ //! cells: Vec<u8>,
39
+ //! #[serde(default)]
40
+ //! kit: Publisher,
41
+ //! #[serde(default)]
42
+ //! receipts: Receipts,
43
+ //! }
44
+ //!
45
+ //! // in `handle`:
46
+ //! "snapshot" => self.kit.snapshot_reply(ctx, "board", &self.view()),
47
+ //! "mark" => Receipts::run(self, |b| &mut b.receipts, &call, ctx.now_ms(), |b| {
48
+ //! let at: Mark = kit::args(&call)?;
49
+ //! b.mark(at)?;
50
+ //! b.kit.publish(ctx, "board", MessageKind::Snapshot, &b.view())?;
51
+ //! encode(&true)
52
+ //! }),
53
+ //! ```
54
+
55
+ use std::collections::{BTreeMap, VecDeque};
56
+
57
+ use serde::de::DeserializeOwned;
58
+ use serde::{Deserialize, Serialize};
59
+
60
+ use crate::model::{Model, Published, Schema, Viewer};
61
+ use crate::{Call, Caller, Ctx, Error, Result, decode, encode};
62
+
63
+ pub mod rng;
64
+ pub mod shard;
65
+ pub mod wire;
66
+
67
+ pub use rng::Rng;
68
+
69
+ /// The protocol version kit messages carry in `v`.
70
+ pub const PROTOCOL: u8 = 1;
71
+ /// The method every kit hub answers with a topic's snapshot.
72
+ pub const SNAPSHOT_METHOD: &str = "snapshot";
73
+ /// The longest `action_id` a call may carry, in bytes.
74
+ pub const ACTION_ID_MAX: usize = 64;
75
+ /// Receipts a hub keeps at most; the oldest go first.
76
+ pub const RECEIPTS_MAX: usize = 256;
77
+ /// How long a receipt is kept.
78
+ pub const RECEIPT_TTL_MS: u64 = 10 * 60 * 1000;
79
+ /// The largest reply a receipt keeps. A call sent again whose first reply was larger is
80
+ /// refused with [`codes::ALREADY_DONE`]: it ran, and the hub's state has its result.
81
+ pub const RECEIPT_REPLY_MAX: usize = 1024;
82
+
83
+ /// What a [`Message`] is.
84
+ #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
85
+ #[serde(rename_all = "snake_case")]
86
+ pub enum MessageKind {
87
+ Snapshot,
88
+ Delta,
89
+ Changed,
90
+ Event,
91
+ }
92
+
93
+ /// The reserved fields of every kit message.
94
+ #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
95
+ pub struct Header {
96
+ pub v: u8,
97
+ pub epoch: u64,
98
+ pub seq: u64,
99
+ pub kind: MessageKind,
100
+ }
101
+
102
+ /// A kit message as it is read back: its header, and the body's own fields beside it.
103
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
104
+ pub struct Message<T> {
105
+ #[serde(flatten)]
106
+ pub header: Header,
107
+ #[serde(flatten)]
108
+ pub body: T,
109
+ }
110
+
111
+ /// The body of a `changed` message.
112
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
113
+ pub struct Changed {
114
+ /// The players who should read again.
115
+ pub who: Vec<u64>,
116
+ /// What changed for them, as the hub's endpoints name it.
117
+ pub what: String,
118
+ }
119
+
120
+ /// The body of a collection's snapshot: every member as a `delta` would show it.
121
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
122
+ pub struct Containers {
123
+ pub containers: Vec<Published>,
124
+ }
125
+
126
+ #[derive(Serialize)]
127
+ struct Outgoing<'a, T: ?Sized> {
128
+ #[serde(flatten)]
129
+ header: Header,
130
+ #[serde(flatten)]
131
+ body: &'a T,
132
+ }
133
+
134
+ /// Encodes one kit message from this instance. `body` must serialize as a struct or a map.
135
+ pub fn message<T: Serialize + ?Sized>(ctx: &Ctx, kind: MessageKind, seq: u64, body: &T) -> Result<Vec<u8>> {
136
+ let header = Header { v: PROTOCOL, epoch: ctx.epoch, seq, kind };
137
+ encode(&Outgoing { header, body })
138
+ }
139
+
140
+ /// A hub's topics and how far each has got. Keep it in the hub's state, so `seq` carries on
141
+ /// after a snapshot; a client that sees another `epoch` asks for a snapshot again anyway.
142
+ #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
143
+ pub struct Publisher {
144
+ #[serde(default)]
145
+ seqs: BTreeMap<String, u64>,
146
+ }
147
+
148
+ impl Publisher {
149
+ pub fn new() -> Self {
150
+ Self::default()
151
+ }
152
+
153
+ /// The topic's last `seq`; 0 before its first message.
154
+ pub fn seq(&self, topic: &str) -> u64 {
155
+ self.seqs.get(topic).copied().unwrap_or(0)
156
+ }
157
+
158
+ /// Publishes one message on `topic`.
159
+ pub fn publish<T: Serialize + ?Sized>(&mut self, ctx: &Ctx, topic: &str, kind: MessageKind, body: &T) -> Result<()> {
160
+ let seq = self.seq(topic) + 1;
161
+ let bytes = message(ctx, kind, seq, body)?;
162
+ self.seqs.insert(topic.to_owned(), seq);
163
+ ctx.publish(topic, &bytes);
164
+ Ok(())
165
+ }
166
+
167
+ /// Publishes the topic's whole state.
168
+ pub fn snapshot<T: Serialize + ?Sized>(&mut self, ctx: &Ctx, topic: &str, body: &T) -> Result<()> {
169
+ self.publish(ctx, topic, MessageKind::Snapshot, body)
170
+ }
171
+
172
+ /// Publishes one change to the topic's collection.
173
+ pub fn delta<T: Serialize + ?Sized>(&mut self, ctx: &Ctx, topic: &str, body: &T) -> Result<()> {
174
+ self.publish(ctx, topic, MessageKind::Delta, body)
175
+ }
176
+
177
+ /// Tells `who` that `what` changed for them; nothing private goes on the topic. Does
178
+ /// nothing when `who` is empty.
179
+ pub fn changed(&mut self, ctx: &Ctx, topic: &str, who: &[u64], what: &str) -> Result<()> {
180
+ if who.is_empty() {
181
+ return Ok(());
182
+ }
183
+ self.publish(ctx, topic, MessageKind::Changed, &Changed { who: who.to_vec(), what: what.to_owned() })
184
+ }
185
+
186
+ /// Publishes something that happened.
187
+ pub fn event<T: Serialize + ?Sized>(&mut self, ctx: &Ctx, topic: &str, body: &T) -> Result<()> {
188
+ self.publish(ctx, topic, MessageKind::Event, body)
189
+ }
190
+
191
+ /// The reply to [`SNAPSHOT_METHOD`] for `topic`: its state now, at its last `seq`.
192
+ pub fn snapshot_reply<T: Serialize + ?Sized>(&self, ctx: &Ctx, topic: &str, body: &T) -> Result<Vec<u8>> {
193
+ message(ctx, MessageKind::Snapshot, self.seq(topic), body)
194
+ }
195
+
196
+ /// Publishes the model's public changes since the last call ([`Model::public_changes`]) as
197
+ /// deltas on `topic`, one container each, and returns how many went out. Owner and hidden
198
+ /// properties are never published.
199
+ pub fn model_changes(&mut self, ctx: &Ctx, model: &mut Model, schema: &Schema, topic: &str) -> Result<usize> {
200
+ let changes = model.public_changes(schema);
201
+ for p in &changes {
202
+ self.delta(ctx, topic, p)?;
203
+ }
204
+ Ok(changes.len())
205
+ }
206
+
207
+ /// The reply to [`SNAPSHOT_METHOD`] for a topic [`Publisher::model_changes`] feeds: every
208
+ /// container's public view, as [`Containers`].
209
+ pub fn model_snapshot_reply(&self, ctx: &Ctx, model: &Model, schema: &Schema, topic: &str) -> Result<Vec<u8>> {
210
+ self.snapshot_reply(ctx, topic, &public_containers(model, schema)?)
211
+ }
212
+ }
213
+
214
+ /// Every container's public view, as a collection snapshot carries it.
215
+ pub fn public_containers(model: &Model, schema: &Schema) -> Result<Containers> {
216
+ let mut containers = Vec::new();
217
+ for id in model.ids() {
218
+ let Some(c) = model.container(id) else { continue };
219
+ let props = model.view(schema, id, Viewer::Public)?;
220
+ containers.push(Published { id, type_name: Some(c.type_name.clone()), props: Some(props), removed: false });
221
+ }
222
+ Ok(Containers { containers })
223
+ }
224
+
225
+ /// The `topic` a [`SNAPSHOT_METHOD`] call names, or `default` when it names none.
226
+ pub fn snapshot_topic(call: &Call<'_>, default: &str) -> String {
227
+ #[derive(Deserialize)]
228
+ struct Ask {
229
+ #[serde(default)]
230
+ topic: Option<String>,
231
+ }
232
+ decode::<Ask>(call.payload).ok().and_then(|a| a.topic).filter(|t| !t.is_empty()).unwrap_or_else(|| default.to_owned())
233
+ }
234
+
235
+ /// A call's arguments, refused with [`codes::BAD_REQUEST`] when they do not decode.
236
+ pub fn args<T: DeserializeOwned>(call: &Call<'_>) -> Result<T> {
237
+ decode(call.payload).map_err(|Error(e)| error(codes::BAD_REQUEST, format!("`{}`: {e}", call.method)))
238
+ }
239
+
240
+ // ---- refusals ----
241
+
242
+ /// The refusal codes kit hubs share. A hub may add its own: lowercase letters, digits and `_`.
243
+ pub mod codes {
244
+ pub const BAD_REQUEST: &str = "bad_request";
245
+ pub const NOT_FOUND: &str = "not_found";
246
+ /// The caller may not do this.
247
+ pub const FORBIDDEN: &str = "forbidden";
248
+ /// The state does not allow it now.
249
+ pub const CONFLICT: &str = "conflict";
250
+ pub const FULL: &str = "full";
251
+ /// A bound the hub keeps (how many, how large).
252
+ pub const LIMIT: &str = "limit";
253
+ /// Too soon after the last time.
254
+ pub const COOLDOWN: &str = "cooldown";
255
+ pub const NOT_YOUR_TURN: &str = "not_your_turn";
256
+ /// The caller has not joined.
257
+ pub const NOT_MEMBER: &str = "not_member";
258
+ pub const STARTED: &str = "started";
259
+ pub const FINISHED: &str = "finished";
260
+ pub const NOT_READY: &str = "not_ready";
261
+ pub const RESERVED: &str = "reserved";
262
+ pub const EMPTY: &str = "empty";
263
+ pub const OUT_OF_REACH: &str = "out_of_reach";
264
+ pub const EXPIRED: &str = "expired";
265
+ /// Not enough of something the caller spends.
266
+ pub const INSUFFICIENT: &str = "insufficient";
267
+ /// The action already ran; its reply was too large to keep ([`super::RECEIPT_REPLY_MAX`]).
268
+ pub const ALREADY_DONE: &str = "already_done";
269
+ }
270
+
271
+ /// A refusal with a code: its message is `kit:<code>: <text>`.
272
+ pub fn error(code: &str, text: impl std::fmt::Display) -> Error {
273
+ debug_assert!(is_code(code), "a kit code is lowercase letters, digits and `_`: {code}");
274
+ Error(format!("kit:{code}: {text}"))
275
+ }
276
+
277
+ fn is_code(code: &str) -> bool {
278
+ !code.is_empty() && code.len() <= 64 && code.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
279
+ }
280
+
281
+ /// The code and text of a refusal [`error`] made, or `None` for any other message.
282
+ pub fn error_code(message: &str) -> Option<(&str, &str)> {
283
+ let rest = message.strip_prefix("kit:")?;
284
+ let (code, text) = rest.split_once(": ")?;
285
+ is_code(code).then_some((code, text))
286
+ }
287
+
288
+ // ---- receipts ----
289
+
290
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
291
+ struct Receipt {
292
+ who: String,
293
+ id: String,
294
+ at: u64,
295
+ ok: bool,
296
+ /// The reply, or the refusal's message; empty with `kept` false when it was too large.
297
+ reply: Vec<u8>,
298
+ kept: bool,
299
+ }
300
+
301
+ /// What each caller's recent actions were answered, by `action_id`. Keep it in the hub's
302
+ /// state: an action sent again after the hub restarted from its last snapshot is answered
303
+ /// from it, if that snapshot came after the action.
304
+ #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
305
+ pub struct Receipts {
306
+ #[serde(default)]
307
+ kept: VecDeque<Receipt>,
308
+ }
309
+
310
+ fn caller_key(caller: Caller) -> Option<String> {
311
+ match caller {
312
+ Caller::Player(p) => Some(format!("p{p}")),
313
+ Caller::Developer(u) => Some(format!("d{u}")),
314
+ Caller::Instance(i) => Some(format!("i{i}")),
315
+ Caller::Platform => None,
316
+ }
317
+ }
318
+
319
+ impl Receipts {
320
+ pub fn new() -> Self {
321
+ Self::default()
322
+ }
323
+
324
+ /// The `action_id` a call's arguments carry: a map's `action_id` string. `None` when the
325
+ /// arguments are not a map or carry none; refused when it is empty or too long.
326
+ pub fn action_id(payload: &[u8]) -> Result<Option<String>> {
327
+ #[derive(Deserialize)]
328
+ struct Carries {
329
+ #[serde(default)]
330
+ action_id: Option<String>,
331
+ }
332
+ let Ok(Carries { action_id }) = decode::<Carries>(payload) else { return Ok(None) };
333
+ match action_id {
334
+ Some(id) if id.is_empty() || id.len() > ACTION_ID_MAX => {
335
+ Err(error(codes::BAD_REQUEST, format!("an action_id is 1 to {ACTION_ID_MAX} bytes")))
336
+ }
337
+ other => Ok(other),
338
+ }
339
+ }
340
+
341
+ /// How many receipts are kept.
342
+ pub fn len(&self) -> usize {
343
+ self.kept.len()
344
+ }
345
+
346
+ pub fn is_empty(&self) -> bool {
347
+ self.kept.is_empty()
348
+ }
349
+
350
+ /// The answer `caller` got for `action_id`, if it is kept.
351
+ pub fn replay(&self, caller: Caller, action_id: Option<&str>, now: u64) -> Option<Result<Vec<u8>>> {
352
+ let (who, id) = (caller_key(caller)?, action_id?);
353
+ let r = self.kept.iter().rev().find(|r| r.who == who && r.id == id && now.saturating_sub(r.at) < RECEIPT_TTL_MS)?;
354
+ Some(match (r.ok, r.kept) {
355
+ (_, false) => Err(error(codes::ALREADY_DONE, "this action already ran; read the state for its result")),
356
+ (true, true) => Ok(r.reply.clone()),
357
+ (false, true) => Err(Error(String::from_utf8_lossy(&r.reply).into_owned())),
358
+ })
359
+ }
360
+
361
+ /// Keeps what `caller` got for `action_id`; nothing without an action id.
362
+ pub fn record(&mut self, caller: Caller, action_id: Option<&str>, result: &Result<Vec<u8>>, now: u64) {
363
+ let (Some(who), Some(id)) = (caller_key(caller), action_id) else { return };
364
+ while self.kept.front().is_some_and(|r| now.saturating_sub(r.at) >= RECEIPT_TTL_MS) || self.kept.len() >= RECEIPTS_MAX {
365
+ self.kept.pop_front();
366
+ }
367
+ let (ok, bytes) = match result {
368
+ Ok(reply) => (true, reply.clone()),
369
+ Err(Error(message)) => (false, message.clone().into_bytes()),
370
+ };
371
+ let kept = bytes.len() <= RECEIPT_REPLY_MAX;
372
+ let reply = if kept { bytes } else { Vec::new() };
373
+ self.kept.push_back(Receipt { who, id: id.to_owned(), at: now, ok, reply, kept });
374
+ }
375
+
376
+ /// Runs `act` on `hub` once per (caller, `action_id`): a call sent again gets the first
377
+ /// answer. `receipts` finds the hub's receipts; a call without an action id just runs.
378
+ pub fn run<H>(
379
+ hub: &mut H,
380
+ receipts: fn(&mut H) -> &mut Receipts,
381
+ call: &Call<'_>,
382
+ now: u64,
383
+ act: impl FnOnce(&mut H) -> Result<Vec<u8>>,
384
+ ) -> Result<Vec<u8>> {
385
+ let id = Self::action_id(call.payload)?;
386
+ if let Some(answer) = receipts(hub).replay(call.caller, id.as_deref(), now) {
387
+ return answer;
388
+ }
389
+ let result = act(hub);
390
+ receipts(hub).record(call.caller, id.as_deref(), &result, now);
391
+ result
392
+ }
393
+ }
394
+
395
+ #[cfg(test)]
396
+ mod tests {
397
+ use super::*;
398
+ use crate::model::{PropKind, Visibility};
399
+
400
+ fn ctx() -> Ctx {
401
+ Ctx { app: 1, instance: 2, epoch: 7, version: 1, kind: crate::Kind::Hub, node_type: "board".into(), key: "k".into() }
402
+ }
403
+
404
+ #[derive(Serialize, Deserialize, Debug, PartialEq)]
405
+ struct Cells {
406
+ cells: Vec<u8>,
407
+ }
408
+
409
+ #[test]
410
+ fn a_message_is_its_body_with_the_header_beside_it() {
411
+ let bytes = message(&ctx(), MessageKind::Snapshot, 3, &Cells { cells: vec![1, 2] }).unwrap();
412
+ let back: Message<Cells> = decode(&bytes).unwrap();
413
+ assert_eq!(back.header, Header { v: PROTOCOL, epoch: 7, seq: 3, kind: MessageKind::Snapshot });
414
+ assert_eq!(back.body, Cells { cells: vec![1, 2] });
415
+ // An older reader of the body alone still reads it.
416
+ assert_eq!(decode::<Cells>(&bytes).unwrap(), Cells { cells: vec![1, 2] });
417
+ assert!(message(&ctx(), MessageKind::Event, 1, &vec![1u8]).is_err(), "a body is a struct or a map");
418
+ }
419
+
420
+ #[test]
421
+ fn each_topic_counts_its_own_messages_and_a_snapshot_reply_names_the_last() {
422
+ let mut p = Publisher::new();
423
+ assert_eq!(p.seq("board"), 0);
424
+ let reply: Message<Cells> = decode(&p.snapshot_reply(&ctx(), "board", &Cells { cells: vec![] }).unwrap()).unwrap();
425
+ assert_eq!(reply.header.seq, 0);
426
+ p.seqs.insert("board".into(), 4);
427
+ p.seqs.insert("chat".into(), 1);
428
+ assert_eq!((p.seq("board"), p.seq("chat"), p.seq("other")), (4, 1, 0));
429
+ }
430
+
431
+ #[test]
432
+ fn codes_lead_a_refusal_and_read_back() {
433
+ let Error(m) = error(codes::NOT_YOUR_TURN, "it is not your turn");
434
+ assert_eq!(m, "kit:not_your_turn: it is not your turn");
435
+ assert_eq!(error_code(&m), Some(("not_your_turn", "it is not your turn")));
436
+ assert_eq!(error_code("decode: invalid type"), None);
437
+ assert_eq!(error_code("kit:Bad Code: x"), None);
438
+ }
439
+
440
+ #[test]
441
+ fn a_receipt_answers_an_action_sent_again_and_only_for_its_caller() {
442
+ let mut r = Receipts::new();
443
+ let id = Some("a1");
444
+ assert!(r.replay(Caller::Player(1), id, 0).is_none());
445
+ r.record(Caller::Player(1), id, &Ok(vec![9]), 0);
446
+ r.record(Caller::Player(1), Some("a2"), &Err(error(codes::FULL, "full")), 0);
447
+ assert_eq!(r.replay(Caller::Player(1), id, 10).unwrap().unwrap(), vec![9]);
448
+ assert_eq!(r.replay(Caller::Player(1), Some("a2"), 10).unwrap().unwrap_err(), error(codes::FULL, "full"));
449
+ assert!(r.replay(Caller::Player(2), id, 10).is_none(), "another caller's id is another action");
450
+ assert!(r.replay(Caller::Player(1), id, RECEIPT_TTL_MS).is_none(), "kept for the TTL only");
451
+ r.record(Caller::Platform, id, &Ok(vec![1]), 0);
452
+ r.record(Caller::Player(1), None, &Ok(vec![1]), 0);
453
+ assert_eq!(r.len(), 2, "no receipt without a caller to answer or an id");
454
+ }
455
+
456
+ #[test]
457
+ fn receipts_are_bounded_and_a_large_reply_is_not_kept() {
458
+ let mut r = Receipts::new();
459
+ for i in 0..RECEIPTS_MAX + 10 {
460
+ r.record(Caller::Player(1), Some(&i.to_string()), &Ok(vec![1]), 0);
461
+ }
462
+ assert_eq!(r.len(), RECEIPTS_MAX);
463
+ assert!(r.replay(Caller::Player(1), Some("0"), 0).is_none(), "the oldest went first");
464
+ r.record(Caller::Player(1), Some("big"), &Ok(vec![0; RECEIPT_REPLY_MAX + 1]), 0);
465
+ let e = r.replay(Caller::Player(1), Some("big"), 0).unwrap().unwrap_err();
466
+ assert_eq!(error_code(&e.0).map(|c| c.0), Some(codes::ALREADY_DONE));
467
+ }
468
+
469
+ #[test]
470
+ fn an_action_id_rides_in_a_map_of_arguments() {
471
+ #[derive(Serialize)]
472
+ struct Act<'a> {
473
+ points: i64,
474
+ action_id: &'a str,
475
+ }
476
+ assert_eq!(Receipts::action_id(&encode(&Act { points: 1, action_id: "x" }).unwrap()).unwrap().as_deref(), Some("x"));
477
+ assert_eq!(Receipts::action_id(&encode(&()).unwrap()).unwrap(), None);
478
+ assert_eq!(Receipts::action_id(&encode(&5u8).unwrap()).unwrap(), None);
479
+ let long = "x".repeat(ACTION_ID_MAX + 1);
480
+ assert!(Receipts::action_id(&encode(&Act { points: 1, action_id: &long }).unwrap()).is_err());
481
+ }
482
+
483
+ #[test]
484
+ fn a_collection_snapshot_holds_every_public_view() {
485
+ let schema = Schema::new()
486
+ .container("Mob", |c| c.prop("hp", PropKind::Int, 10, Visibility::Public).prop("seed", PropKind::Int, 0, Visibility::Hidden));
487
+ let mut model = Model::new();
488
+ model.create(&schema, "Mob", None).unwrap();
489
+ model.create(&schema, "Mob", Some(3)).unwrap();
490
+ let all = public_containers(&model, &schema).unwrap();
491
+ assert_eq!(all.containers.len(), 2);
492
+ assert!(all.containers.iter().all(|c| c.props.as_ref().is_some_and(|p| p.contains_key("hp") && !p.contains_key("seed"))));
493
+ }
494
+ }
@@ -0,0 +1,127 @@
1
+ //! A seeded random number generator (xorshift64*), plain data so it lives in a hub's state
2
+ //! and carries on after a snapshot. Seed it from something the hub already has: its key, the
3
+ //! clock at spawn, a match's seed.
4
+
5
+ use serde::{Deserialize, Serialize};
6
+
7
+ #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
8
+ pub struct Rng {
9
+ state: u64,
10
+ }
11
+
12
+ impl Default for Rng {
13
+ fn default() -> Self {
14
+ Self::seeded(1)
15
+ }
16
+ }
17
+
18
+ impl Rng {
19
+ /// A generator seeded with `seed`; 0 counts as 1, which xorshift needs.
20
+ pub fn seeded(seed: u64) -> Self {
21
+ Self { state: seed.max(1) }
22
+ }
23
+
24
+ /// A seed from text: a key, a name.
25
+ pub fn seed_from(text: &str) -> u64 {
26
+ // FNV-1a.
27
+ text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3))
28
+ }
29
+
30
+ pub fn next_u64(&mut self) -> u64 {
31
+ let mut s = self.state;
32
+ s ^= s >> 12;
33
+ s ^= s << 25;
34
+ s ^= s >> 27;
35
+ self.state = s;
36
+ s.wrapping_mul(0x2545_f491_4f6c_dd1d)
37
+ }
38
+
39
+ /// Uniform in `[0, 1)`.
40
+ pub fn f64(&mut self) -> f64 {
41
+ (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
42
+ }
43
+
44
+ /// Uniform in `[0, n)`; 0 when `n` is 0.
45
+ pub fn below(&mut self, n: u64) -> u64 {
46
+ if n == 0 { 0 } else { self.next_u64() % n }
47
+ }
48
+
49
+ /// Uniform in `[lo, hi]`, both included; `lo` when `hi` is not above it.
50
+ pub fn range(&mut self, lo: i64, hi: i64) -> i64 {
51
+ if hi <= lo {
52
+ return lo;
53
+ }
54
+ let span = hi.abs_diff(lo).saturating_add(1);
55
+ lo.wrapping_add(self.below(span) as i64)
56
+ }
57
+
58
+ /// True with probability `p` (clamped to `[0, 1]`).
59
+ pub fn chance(&mut self, p: f64) -> bool {
60
+ self.f64() < p.clamp(0.0, 1.0)
61
+ }
62
+
63
+ pub fn pick<'a, T>(&mut self, items: &'a [T]) -> Option<&'a T> {
64
+ if items.is_empty() { None } else { items.get(self.below(items.len() as u64) as usize) }
65
+ }
66
+
67
+ /// The index of one weight, chosen in proportion to the weights; `None` when they sum to
68
+ /// nothing.
69
+ pub fn weighted(&mut self, weights: &[u64]) -> Option<usize> {
70
+ let total: u64 = weights.iter().fold(0u64, |a, w| a.saturating_add(*w));
71
+ if total == 0 {
72
+ return None;
73
+ }
74
+ let mut roll = self.below(total);
75
+ for (i, w) in weights.iter().enumerate() {
76
+ if roll < *w {
77
+ return Some(i);
78
+ }
79
+ roll -= w;
80
+ }
81
+ None
82
+ }
83
+
84
+ /// Shuffles in place (Fisher-Yates).
85
+ pub fn shuffle<T>(&mut self, items: &mut [T]) {
86
+ for i in (1..items.len()).rev() {
87
+ let j = self.below(i as u64 + 1) as usize;
88
+ items.swap(i, j);
89
+ }
90
+ }
91
+ }
92
+
93
+ #[cfg(test)]
94
+ mod tests {
95
+ use super::*;
96
+
97
+ #[test]
98
+ fn deterministic_and_bounded() {
99
+ let (mut a, mut b) = (Rng::seeded(42), Rng::seeded(42));
100
+ for _ in 0..100 {
101
+ assert_eq!(a.next_u64(), b.next_u64());
102
+ }
103
+ for _ in 0..1_000 {
104
+ assert!((0.0..1.0).contains(&a.f64()));
105
+ assert!((-5..=5).contains(&a.range(-5, 5)));
106
+ assert!(a.below(3) < 3);
107
+ }
108
+ // The widest span does not overflow.
109
+ let _ = a.range(i64::MIN, i64::MAX);
110
+ assert_eq!(a.range(4, 4), 4);
111
+ assert!(a.pick::<u8>(&[]).is_none());
112
+ assert_eq!(a.weighted(&[0, 0]), None);
113
+ assert_eq!(a.weighted(&[0, 5, 0]), Some(1));
114
+ }
115
+
116
+ #[test]
117
+ fn a_shuffle_keeps_every_item_and_seeds_from_text_are_stable() {
118
+ let mut items: Vec<u32> = (0..52).collect();
119
+ Rng::seeded(Rng::seed_from("table-1")).shuffle(&mut items);
120
+ let mut sorted = items.clone();
121
+ sorted.sort_unstable();
122
+ assert_eq!(sorted, (0..52).collect::<Vec<_>>());
123
+ assert_ne!(items, sorted);
124
+ assert_eq!(Rng::seed_from("a"), Rng::seed_from("a"));
125
+ assert_ne!(Rng::seed_from("a"), Rng::seed_from("b"));
126
+ }
127
+ }