@crowdedkingdoms/ckx-kit 0.3.0-dev.1 → 0.5.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +9 -2
  2. package/crates/ckx-sdk/Cargo.toml +1 -1
  3. package/crates/ckx-sdk/src/kit/ai/brain.rs +288 -0
  4. package/crates/ckx-sdk/src/kit/ai/flow.rs +163 -0
  5. package/crates/ckx-sdk/src/kit/ai/mod.rs +10 -0
  6. package/crates/ckx-sdk/src/kit/ai/path.rs +214 -0
  7. package/crates/ckx-sdk/src/kit/ai/steering.rs +137 -0
  8. package/crates/ckx-sdk/src/kit/ai/turn.rs +141 -0
  9. package/crates/ckx-sdk/src/kit/econ/holdings.rs +241 -0
  10. package/crates/ckx-sdk/src/kit/econ/holds.rs +202 -0
  11. package/crates/ckx-sdk/src/kit/econ/items.rs +212 -0
  12. package/crates/ckx-sdk/src/kit/econ/loot.rs +116 -0
  13. package/crates/ckx-sdk/src/kit/econ/mod.rs +50 -0
  14. package/crates/ckx-sdk/src/kit/econ/orderbook.rs +155 -0
  15. package/crates/ckx-sdk/src/kit/econ/production.rs +155 -0
  16. package/crates/ckx-sdk/src/kit/econ/progression.rs +254 -0
  17. package/crates/ckx-sdk/src/kit/econ/recipes.rs +65 -0
  18. package/crates/ckx-sdk/src/kit/econ/vendor.rs +116 -0
  19. package/crates/ckx-sdk/src/kit/econ/wallet.rs +67 -0
  20. package/crates/ckx-sdk/src/kit/mod.rs +72 -3
  21. package/crates/ckx-sdk/src/kit/play/abilities.rs +370 -0
  22. package/crates/ckx-sdk/src/kit/play/cards.rs +220 -0
  23. package/crates/ckx-sdk/src/kit/play/combat.rs +292 -0
  24. package/crates/ckx-sdk/src/kit/play/mod.rs +15 -0
  25. package/crates/ckx-sdk/src/kit/play/movement.rs +196 -0
  26. package/crates/ckx-sdk/src/kit/play/score.rs +183 -0
  27. package/crates/ckx-sdk/src/kit/play/timing.rs +218 -0
  28. package/crates/ckx-sdk/src/kit/play/turns.rs +315 -0
  29. package/crates/ckx-sdk/src/kit/presence.rs +142 -0
  30. package/crates/ckx-sdk/src/kit/rng.rs +10 -0
  31. package/crates/ckx-sdk/src/kit/sim/daycycle.rs +71 -0
  32. package/crates/ckx-sdk/src/kit/sim/mod.rs +10 -0
  33. package/crates/ckx-sdk/src/kit/sim/nodes.rs +109 -0
  34. package/crates/ckx-sdk/src/kit/sim/waves.rs +105 -0
  35. package/crates/ckx-sdk/src/kit/sim/weather.rs +142 -0
  36. package/crates/ckx-sdk/src/kit/sim/zones.rs +218 -0
  37. package/crates/ckx-sdk/src/lib.rs +2 -1
  38. package/dist/cli/src/new.js +11 -0
  39. package/kit.pin +4 -4
  40. package/package.json +1 -1
  41. package/starters/arena/Cargo.toml +14 -0
  42. package/starters/arena/src/lib.rs +849 -0
  43. package/starters/ckx.json +14 -1
  44. package/starters/deck/Cargo.toml +14 -0
  45. package/starters/deck/src/lib.rs +510 -0
  46. package/starters/director/Cargo.toml +14 -0
  47. package/starters/director/src/lib.rs +628 -0
  48. package/starters/grid-mod/Cargo.toml +1 -1
  49. package/starters/instance/Cargo.toml +14 -0
  50. package/starters/instance/src/lib.rs +656 -0
  51. package/starters/leaderboard/Cargo.toml +14 -0
  52. package/starters/leaderboard/src/lib.rs +395 -0
  53. package/starters/market/Cargo.toml +14 -0
  54. package/starters/market/src/lib.rs +1137 -0
  55. package/starters/matchmaker/Cargo.toml +1 -1
  56. package/starters/minigame/Cargo.toml +14 -0
  57. package/starters/minigame/src/lib.rs +391 -0
  58. package/starters/npc-mobs/Cargo.toml +1 -1
  59. package/starters/player/Cargo.toml +14 -0
  60. package/starters/player/src/lib.rs +994 -0
  61. package/starters/racing/Cargo.toml +14 -0
  62. package/starters/racing/src/lib.rs +1151 -0
  63. package/starters/session/Cargo.toml +1 -1
  64. package/starters/tactics/Cargo.toml +14 -0
  65. package/starters/tactics/src/lib.rs +562 -0
  66. package/starters/territory/Cargo.toml +14 -0
  67. package/starters/territory/src/lib.rs +878 -0
  68. package/starters/world-tick/Cargo.toml +1 -1
@@ -0,0 +1,1137 @@
1
+ //! A market, keyed by its name (`town`, `harbor`): vendors with fixed prices whose stock
2
+ //! restocks on a schedule, an order book for each item it lists, and trades between two players.
3
+ //! It holds nothing itself. What a player puts up waits in a hold in their own `player` hub
4
+ //! (`ckx_sdk::kit::econ::holds`), and the market drives each transfer, a settle from the giver's
5
+ //! hold then a deposit to the taker, as steps their hubs answer once by reference; it retries a
6
+ //! step until its hub answers. It is not a ledger (`ckx_sdk::kit::econ`).
7
+ //!
8
+ //! Each change it asks of a player's hub has a reference of the market's own (its key, what the
9
+ //! change is for, its placement's epoch and a count), never one built from what a client sent, so
10
+ //! a hub cannot take one change for another. Its key is at most [`KEY_MAX`] bytes.
11
+ //!
12
+ //! Endpoints (players): `vendors`, `buy { vendor, item, count }`, `sell { vendor, item, count }`,
13
+ //! `book { item }`, `order { item, side, price, count }`, `cancel { order }`, `orders`,
14
+ //! `trade { with }`, `offer { trade, bundle }`, `accept { trade }`, `decline { trade }`,
15
+ //! `trades`, `state` (anyone). A change that needs a player's hub and could not hear from it is
16
+ //! refused `unavailable`: send it again with the same `action_id`, and it finishes what it began.
17
+ //! It speaks the kit protocol (`ckx_sdk::kit`): the `market` topic carries the vendors' stock and
18
+ //! each book's best prices as `snapshot` messages, at most once a second, and `changed` messages
19
+ //! naming the players whose orders or trades moved (`what`: `orders`, `trades`);
20
+ //! `snapshot { topic: "market" }` answers it; refusals carry `kit:<code>:` codes.
21
+
22
+ use std::collections::{BTreeMap, BTreeSet, VecDeque};
23
+
24
+ use ckx_sdk::kit::econ::orderbook::{OrderBook, Side};
25
+ use ckx_sdk::kit::econ::vendor::{Offer, Shelf, Vendor};
26
+ use ckx_sdk::kit::econ::{self, Bundle};
27
+ use ckx_sdk::kit::{self, Publisher, Receipts, codes};
28
+ use ckx_sdk::prelude::*;
29
+ use serde_json::{Value, json};
30
+
31
+ pub const TOPIC: &str = "market";
32
+ const PUBLISH_MS: u64 = 1_000;
33
+ /// How often unfinished steps are tried again.
34
+ const STEPS_MS: u64 = 1_000;
35
+ /// Steps one call drives before it answers; the timer drives the rest.
36
+ const STEPS_PER_CALL: usize = 16;
37
+ /// A change whose answer was lost, and that no resend finished, is forgotten after this long; a
38
+ /// hold among them is given back.
39
+ const PENDING_MS: u64 = 60_000;
40
+ const DEPTH: usize = 10;
41
+ /// The longest key a market takes.
42
+ pub const KEY_MAX: usize = 48;
43
+ // Its longest reference (key, `:t` and a trade id, `:` epoch `.` count, a step's `:s`) fits a
44
+ // player's hub.
45
+ const _: () = assert!(KEY_MAX + 2 + 20 + 1 + 20 + 1 + 20 + 2 <= econ::REFERENCE_MAX);
46
+
47
+ #[derive(Serialize, Deserialize, Clone, Debug)]
48
+ #[serde(default)]
49
+ pub struct Config {
50
+ pub vendors: Vec<Vendor>,
51
+ /// The items with an order book, and the currency they trade in.
52
+ pub book_items: Vec<String>,
53
+ pub book_currency: String,
54
+ pub orders_max: usize,
55
+ /// How long a trade stays open.
56
+ pub trade_ms: u64,
57
+ /// The node type of the players' hubs.
58
+ pub player_type: String,
59
+ }
60
+
61
+ impl Default for Config {
62
+ fn default() -> Self {
63
+ let offer = |item: &str, price: i64, stock: Option<u64>, per_player: Option<u64>, buys_for: Option<i64>| Offer {
64
+ item: item.into(),
65
+ price: Bundle::coins("gold", price),
66
+ stock,
67
+ per_player,
68
+ buys_for: buys_for.map(|p| Bundle::coins("gold", p)),
69
+ };
70
+ Self {
71
+ vendors: vec![Vendor {
72
+ id: "smith".into(),
73
+ offers: vec![offer("sword", 50, Some(5), Some(1), Some(20)), offer("potion", 5, None, None, Some(2))],
74
+ restock: Some("0 * * * *".into()),
75
+ }],
76
+ book_items: vec!["wood".into(), "plank".into(), "ore".into(), "ingot".into()],
77
+ book_currency: "gold".into(),
78
+ orders_max: 20,
79
+ trade_ms: 5 * 60_000,
80
+ player_type: "player".into(),
81
+ }
82
+ }
83
+ }
84
+
85
+ /// An order the market keeps beside its book entry.
86
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
87
+ pub struct OrderMeta {
88
+ pub id: u64,
89
+ pub player: u64,
90
+ pub item: String,
91
+ pub side: Side,
92
+ pub price: i64,
93
+ pub count: u64,
94
+ /// What is still open.
95
+ pub open: u64,
96
+ /// The hold that backs it, in the player's hub.
97
+ pub hold: String,
98
+ }
99
+
100
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
101
+ #[serde(rename_all = "snake_case")]
102
+ pub enum TradeState {
103
+ Open,
104
+ Settling,
105
+ Done,
106
+ Cancelled,
107
+ }
108
+
109
+ #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
110
+ pub struct Side_ {
111
+ pub bundle: Bundle,
112
+ pub hold: Option<String>,
113
+ pub accepted: bool,
114
+ }
115
+
116
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
117
+ pub struct Trade {
118
+ pub id: u64,
119
+ pub players: [u64; 2],
120
+ pub sides: [Side_; 2],
121
+ pub state: TradeState,
122
+ pub expires_at: u64,
123
+ /// Offers made so far.
124
+ pub offers: u64,
125
+ }
126
+
127
+ impl Trade {
128
+ fn side(&self, player: u64) -> Option<usize> {
129
+ self.players.iter().position(|p| *p == player)
130
+ }
131
+ }
132
+
133
+ /// One step of a transfer, for a player's hub.
134
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
135
+ #[serde(tag = "kind", rename_all = "snake_case")]
136
+ pub enum Step {
137
+ /// Takes `bundle` out of `hold` in `from`'s hub, then gives it to `to`: the settle by `ref`
138
+ /// and `:s`, the deposit by `ref` and `:d`.
139
+ Transfer { from: u64, to: u64, hold: String, bundle: Bundle, r#ref: String, settled: bool, trade: Option<u64> },
140
+ /// Gives what is left of `hold` back to `player`.
141
+ Release { player: u64, hold: String, r#ref: String, trade: Option<u64> },
142
+ }
143
+
144
+ impl Step {
145
+ fn player(&self) -> u64 {
146
+ match self {
147
+ Step::Transfer { from, to, settled, .. } => {
148
+ if *settled {
149
+ *to
150
+ } else {
151
+ *from
152
+ }
153
+ }
154
+ Step::Release { player, .. } => *player,
155
+ }
156
+ }
157
+
158
+ fn trade(&self) -> Option<u64> {
159
+ match self {
160
+ Step::Transfer { trade, .. } | Step::Release { trade, .. } => *trade,
161
+ }
162
+ }
163
+ }
164
+
165
+ /// Who to tell, and what changed for them.
166
+ type Tell = Vec<(u64, &'static str)>;
167
+
168
+ /// What a player's hub said.
169
+ enum Heard {
170
+ Done(Vec<u8>),
171
+ Refused(String),
172
+ /// It may or may not have run: ask again.
173
+ Unknown(String),
174
+ }
175
+
176
+ /// A change asked of a player's hub whose answer was lost: it may or may not have run.
177
+ #[derive(Serialize, Deserialize, Clone, Debug)]
178
+ pub struct Unknown {
179
+ pub player: u64,
180
+ /// The `action_id` of the call that asked: a resend with it asks again by the same reference.
181
+ pub action: Option<String>,
182
+ pub method: String,
183
+ pub args: Value,
184
+ pub at: u64,
185
+ }
186
+
187
+ #[derive(Serialize, Deserialize, Clone, Debug, Default)]
188
+ #[serde(default)]
189
+ pub struct Market {
190
+ pub config: Config,
191
+ pub shelves: BTreeMap<String, Shelf>,
192
+ pub books: BTreeMap<String, OrderBook>,
193
+ pub orders: BTreeMap<u64, OrderMeta>,
194
+ pub trades: BTreeMap<u64, Trade>,
195
+ pub steps: VecDeque<Step>,
196
+ /// Changes asked of a player's hub whose answer was lost, by reference.
197
+ pub unknown: BTreeMap<String, Unknown>,
198
+ /// The last trade price of each item.
199
+ pub last: BTreeMap<String, i64>,
200
+ pub next_id: u64,
201
+ pub next_ref: u64,
202
+ /// Steps a hub refused, which nothing will retry; an operator reads `state`.
203
+ pub stuck: u64,
204
+ pub dirty: bool,
205
+ }
206
+
207
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
208
+ pub struct OfferView {
209
+ pub item: String,
210
+ pub price: Bundle,
211
+ /// Left on the shelf; none for no limit.
212
+ pub stock: Option<u64>,
213
+ pub per_player: Option<u64>,
214
+ pub buys_for: Option<Bundle>,
215
+ }
216
+
217
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
218
+ pub struct VendorView {
219
+ pub id: String,
220
+ pub offers: Vec<OfferView>,
221
+ pub restocked_at: u64,
222
+ }
223
+
224
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
225
+ pub struct Quote {
226
+ pub bid: Option<i64>,
227
+ pub ask: Option<i64>,
228
+ pub last: Option<i64>,
229
+ }
230
+
231
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
232
+ pub struct MarketView {
233
+ pub vendors: Vec<VendorView>,
234
+ pub currency: String,
235
+ pub quotes: BTreeMap<String, Quote>,
236
+ pub open_trades: usize,
237
+ pub pending_steps: usize,
238
+ }
239
+
240
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
241
+ pub struct BookView {
242
+ pub item: String,
243
+ pub currency: String,
244
+ pub bids: Vec<(i64, u64)>,
245
+ pub asks: Vec<(i64, u64)>,
246
+ pub last: Option<i64>,
247
+ }
248
+
249
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
250
+ pub struct Placed {
251
+ pub order: u64,
252
+ /// `(price, count)` it traded at once.
253
+ pub fills: Vec<(i64, u64)>,
254
+ /// What rests on the book.
255
+ pub open: u64,
256
+ }
257
+
258
+ #[derive(Deserialize)]
259
+ struct Shop {
260
+ vendor: String,
261
+ item: String,
262
+ #[serde(default = "one")]
263
+ count: u64,
264
+ }
265
+
266
+ fn one() -> u64 {
267
+ 1
268
+ }
269
+
270
+ #[derive(Deserialize)]
271
+ struct Item {
272
+ item: String,
273
+ }
274
+
275
+ #[derive(Deserialize)]
276
+ struct Place {
277
+ item: String,
278
+ side: Side,
279
+ price: i64,
280
+ count: u64,
281
+ }
282
+
283
+ #[derive(Deserialize)]
284
+ struct Cancel {
285
+ order: u64,
286
+ }
287
+
288
+ #[derive(Deserialize)]
289
+ struct With {
290
+ with: u64,
291
+ }
292
+
293
+ #[derive(Deserialize)]
294
+ struct OfferArgs {
295
+ trade: u64,
296
+ #[serde(default)]
297
+ bundle: Bundle,
298
+ }
299
+
300
+ #[derive(Deserialize)]
301
+ struct TradeArgs {
302
+ trade: u64,
303
+ }
304
+
305
+ fn definite(status: Status) -> bool {
306
+ matches!(status, Status::AppError | Status::NotFound | Status::Denied | Status::BadRequest)
307
+ }
308
+
309
+ impl Market {
310
+ pub fn new(config: Config, now: u64) -> Self {
311
+ let mut m = Market { config, dirty: true, ..Market::default() };
312
+ for v in m.config.vendors.clone() {
313
+ m.shelves.entry(v.id.clone()).or_default().restock(&v, now);
314
+ }
315
+ m
316
+ }
317
+
318
+ fn vendor(&self, id: &str) -> Result<&Vendor> {
319
+ self.config.vendors.iter().find(|v| v.id == id).ok_or_else(|| kit::error(codes::NOT_FOUND, format!("there is no vendor `{id}`")))
320
+ }
321
+
322
+ pub fn view(&self) -> MarketView {
323
+ MarketView {
324
+ vendors: self.vendors(),
325
+ currency: self.config.book_currency.clone(),
326
+ quotes: self
327
+ .config
328
+ .book_items
329
+ .iter()
330
+ .map(|i| {
331
+ let book = self.books.get(i);
332
+ (
333
+ i.clone(),
334
+ Quote {
335
+ bid: book.and_then(|b| b.bids.first()).map(|o| o.price),
336
+ ask: book.and_then(|b| b.asks.first()).map(|o| o.price),
337
+ last: self.last.get(i).copied(),
338
+ },
339
+ )
340
+ })
341
+ .collect(),
342
+ open_trades: self.trades.values().filter(|t| t.state == TradeState::Open).count(),
343
+ pending_steps: self.steps.len(),
344
+ }
345
+ }
346
+
347
+ pub fn vendors(&self) -> Vec<VendorView> {
348
+ self.config
349
+ .vendors
350
+ .iter()
351
+ .map(|v| {
352
+ let shelf = self.shelves.get(&v.id);
353
+ VendorView {
354
+ id: v.id.clone(),
355
+ offers: v
356
+ .offers
357
+ .iter()
358
+ .map(|o| OfferView {
359
+ item: o.item.clone(),
360
+ price: o.price.clone(),
361
+ stock: o.stock.map(|_| shelf.and_then(|s| s.stock.get(&o.item)).copied().unwrap_or(0)),
362
+ per_player: o.per_player,
363
+ buys_for: o.buys_for.clone(),
364
+ })
365
+ .collect(),
366
+ restocked_at: shelf.map(|s| s.restocked_at).unwrap_or(0),
367
+ }
368
+ })
369
+ .collect()
370
+ }
371
+
372
+ pub fn book(&self, item: &str) -> BookView {
373
+ let book = self.books.get(item).cloned().unwrap_or_default();
374
+ BookView {
375
+ item: item.to_owned(),
376
+ currency: self.config.book_currency.clone(),
377
+ bids: book.depth(Side::Buy, DEPTH),
378
+ asks: book.depth(Side::Sell, DEPTH),
379
+ last: self.last.get(item).copied(),
380
+ }
381
+ }
382
+
383
+ pub fn orders_of(&self, player: u64) -> Vec<OrderMeta> {
384
+ self.orders.values().filter(|o| o.player == player && o.open > 0).cloned().collect()
385
+ }
386
+
387
+ pub fn trades_of(&self, player: u64) -> Vec<Trade> {
388
+ self.trades
389
+ .values()
390
+ .filter(|t| t.players.contains(&player) && matches!(t.state, TradeState::Open | TradeState::Settling))
391
+ .cloned()
392
+ .collect()
393
+ }
394
+
395
+ /// A reference no other change of this market has: its key, what it is for, and its
396
+ /// placement's epoch with a count. A placement raises the epoch, so a count a crash took back
397
+ /// is never sent again.
398
+ fn fresh(&mut self, ctx: &Ctx, what: &str) -> String {
399
+ self.next_ref += 1;
400
+ format!("{}:{what}:{}.{}", ctx.key, ctx.epoch, self.next_ref)
401
+ }
402
+
403
+ fn hear(&self, ctx: &Ctx, player: u64, method: &str, args: &Value) -> Heard {
404
+ let payload = match encode(args) {
405
+ Ok(p) => p,
406
+ Err(e) => return Heard::Refused(e.0),
407
+ };
408
+ match ctx.call(&self.config.player_type, &player.to_string(), method, &payload) {
409
+ Ok(reply) => Heard::Done(reply),
410
+ Err(e) if definite(e.status) => Heard::Refused(e.message),
411
+ Err(e) => Heard::Unknown(e.message),
412
+ }
413
+ }
414
+
415
+ /// Asks `player`'s hub for a change a client's call needs now, by a fresh reference; the hub's
416
+ /// refusal is the call's. A lost answer refuses the call `unavailable` and keeps the change
417
+ /// with the call's `action_id`: a resend with it and the same arguments asks again by the
418
+ /// same reference, so the hub runs it once, and one with other arguments is refused
419
+ /// `conflict`. Answers the reference and the hub's reply.
420
+ fn change(&mut self, ctx: &Ctx, call: &Call<'_>, player: u64, what: &str, method: &str, args: Value) -> Result<(String, Vec<u8>)> {
421
+ let action = Receipts::action_id(call.payload)?;
422
+ let resent = action.as_deref().and_then(|a| {
423
+ self.unknown
424
+ .iter()
425
+ .find(|(_, u)| u.player == player && u.action.as_deref() == Some(a))
426
+ .map(|(r, u)| (r.clone(), u.method == method && u.args == args))
427
+ });
428
+ let reference = match resent {
429
+ Some((r, true)) => r,
430
+ Some((_, false)) => {
431
+ return Err(kit::error(codes::CONFLICT, "that action_id asked for another change; send this one with its own"));
432
+ }
433
+ None => self.fresh(ctx, what),
434
+ };
435
+ let mut sent = args.clone();
436
+ sent["ref"] = Value::String(reference.clone());
437
+ match self.hear(ctx, player, method, &sent) {
438
+ Heard::Done(reply) => {
439
+ self.unknown.remove(&reference);
440
+ Ok((reference, reply))
441
+ }
442
+ Heard::Refused(message) => {
443
+ self.unknown.remove(&reference);
444
+ Err(Error::new(message))
445
+ }
446
+ Heard::Unknown(message) => {
447
+ if action.is_some() || method == "hold" {
448
+ self.unknown.insert(reference, Unknown { player, action, method: method.to_owned(), args, at: ctx.now_ms() });
449
+ }
450
+ Err(kit::error(codes::UNAVAILABLE, format!("the player's hub did not answer ({message}); send it again")))
451
+ }
452
+ }
453
+ }
454
+
455
+ /// Drives the steps, each player's in order; a player whose hub did not answer waits.
456
+ pub fn drive(&mut self, ctx: &Ctx, budget: usize) -> Result<()> {
457
+ let mut waiting: BTreeSet<u64> = BTreeSet::new();
458
+ let mut touched: BTreeSet<u64> = BTreeSet::new();
459
+ let mut finished_trades: BTreeSet<u64> = BTreeSet::new();
460
+ let (mut i, mut tries) = (0, 0);
461
+ while i < self.steps.len() && tries < budget {
462
+ let player = self.steps[i].player();
463
+ if waiting.contains(&player) {
464
+ i += 1;
465
+ continue;
466
+ }
467
+ tries += 1;
468
+ let (method, args) = match &self.steps[i] {
469
+ Step::Transfer { hold, bundle, r#ref, settled: false, .. } => {
470
+ ("settle", json!({ "ref": format!("{ref}:s"), "hold": hold, "bundle": bundle }))
471
+ }
472
+ Step::Transfer { bundle, r#ref, settled: true, .. } => ("deposit", json!({ "ref": format!("{ref}:d"), "bundle": bundle })),
473
+ Step::Release { hold, r#ref, .. } => ("release", json!({ "ref": r#ref, "hold": hold })),
474
+ };
475
+ match self.hear(ctx, player, method, &args) {
476
+ Heard::Unknown(message) => {
477
+ ctx.log(Level::Warn, &format!("step for player {player} unanswered ({message}); trying again"));
478
+ waiting.insert(player);
479
+ i += 1;
480
+ continue;
481
+ }
482
+ Heard::Refused(message) => {
483
+ ctx.log(Level::Error, &format!("player {player}'s hub refused {method} ({message}); the transfer is dropped"));
484
+ self.stuck += 1;
485
+ }
486
+ Heard::Done(_) => {
487
+ if let Step::Transfer { settled: settled @ false, .. } = &mut self.steps[i] {
488
+ *settled = true;
489
+ touched.insert(player);
490
+ continue;
491
+ }
492
+ }
493
+ }
494
+ touched.insert(player);
495
+ let step = self.steps.remove(i).expect("the step at i");
496
+ if let Some(t) = step.trade()
497
+ && !self.steps.iter().any(|s| s.trade() == Some(t))
498
+ {
499
+ finished_trades.insert(t);
500
+ }
501
+ }
502
+ for t in finished_trades {
503
+ if let Some(trade) = self.trades.get_mut(&t)
504
+ && trade.state == TradeState::Settling
505
+ {
506
+ trade.state = TradeState::Done;
507
+ touched.extend(trade.players);
508
+ }
509
+ }
510
+ if !touched.is_empty() {
511
+ self.dirty = true;
512
+ }
513
+ Ok(())
514
+ }
515
+
516
+ fn act(&mut self, ctx: &Ctx, call: &Call<'_>, now: u64) -> Result<(Vec<u8>, Tell)> {
517
+ let player = call.player()?;
518
+ match call.method {
519
+ "buy" | "sell" => {
520
+ let s: Shop = kit::args(call)?;
521
+ let vendor = self.vendor(&s.vendor)?.clone();
522
+ let offer = vendor.offer(&s.item)?.clone();
523
+ let (pay, get) = if call.method == "buy" {
524
+ self.shelves.entry(vendor.id.clone()).or_default().check(&offer, player, s.count)?;
525
+ (offer.price.times(s.count)?, Bundle::item(&offer.item, s.count))
526
+ } else {
527
+ let price = offer
528
+ .buys_for
529
+ .clone()
530
+ .ok_or_else(|| kit::error(codes::FORBIDDEN, format!("`{}` does not buy `{}`", vendor.id, offer.item)))?;
531
+ (Bundle::item(&offer.item, s.count), price.times(s.count)?)
532
+ };
533
+ self.change(ctx, call, player, &call.method[..1], "exchange", json!({ "pay": pay, "get": get }))?;
534
+ if call.method == "buy" {
535
+ self.shelves.entry(vendor.id).or_default().sold(&offer, player, s.count);
536
+ }
537
+ self.dirty = true;
538
+ Ok((encode(&get)?, vec![]))
539
+ }
540
+ "order" => {
541
+ let p: Place = kit::args(call)?;
542
+ if !self.config.book_items.contains(&p.item) {
543
+ return Err(kit::error(codes::NOT_FOUND, format!("`{}` has no book here", p.item)));
544
+ }
545
+ if p.price <= 0 || p.count == 0 {
546
+ return Err(kit::error(codes::BAD_REQUEST, "a price and a count are positive"));
547
+ }
548
+ if self.orders_of(player).len() >= self.config.orders_max {
549
+ return Err(kit::error(codes::LIMIT, format!("{} open orders at most", self.config.orders_max)));
550
+ }
551
+ let held = match p.side {
552
+ Side::Buy => Bundle::coins(
553
+ &self.config.book_currency,
554
+ p.price
555
+ .checked_mul(i64::try_from(p.count).unwrap_or(i64::MAX))
556
+ .ok_or_else(|| kit::error(codes::LIMIT, "too much"))?,
557
+ ),
558
+ Side::Sell => Bundle::item(&p.item, p.count),
559
+ };
560
+ let (hold, _) = self.change(ctx, call, player, "o", "hold", json!({ "bundle": held }))?;
561
+ self.next_id += 1;
562
+ let id = self.next_id;
563
+ let fills = self.books.entry(p.item.clone()).or_default().place(id, player, p.side, p.price, p.count, now)?;
564
+ let mut tell = vec![(player, "orders")];
565
+ let filled: u64 = fills.iter().map(|f| f.count).sum();
566
+ self.orders.insert(
567
+ id,
568
+ OrderMeta { id, player, item: p.item.clone(), side: p.side, price: p.price, count: p.count, open: p.count, hold },
569
+ );
570
+ for f in &fills {
571
+ let (buy, sell) = (self.orders[&f.buy_order].clone(), self.orders[&f.sell_order].clone());
572
+ let (items_ref, coins_ref) = (self.fresh(ctx, "x"), self.fresh(ctx, "x"));
573
+ self.steps.push_back(Step::Transfer {
574
+ from: f.seller,
575
+ to: f.buyer,
576
+ hold: sell.hold.clone(),
577
+ bundle: Bundle::item(&p.item, f.count),
578
+ r#ref: items_ref,
579
+ settled: false,
580
+ trade: None,
581
+ });
582
+ self.steps.push_back(Step::Transfer {
583
+ from: f.buyer,
584
+ to: f.seller,
585
+ hold: buy.hold.clone(),
586
+ bundle: Bundle::coins(
587
+ &self.config.book_currency,
588
+ f.price.saturating_mul(i64::try_from(f.count).unwrap_or(i64::MAX)),
589
+ ),
590
+ r#ref: coins_ref,
591
+ settled: false,
592
+ trade: None,
593
+ });
594
+ for o in [f.buy_order, f.sell_order] {
595
+ let meta = self.orders.get_mut(&o).expect("a filled order");
596
+ meta.open -= f.count;
597
+ if meta.open == 0 {
598
+ let (owner, hold) = (meta.player, meta.hold.clone());
599
+ self.steps.push_back(Step::Release { player: owner, r#ref: format!("{hold}:r"), hold, trade: None });
600
+ }
601
+ }
602
+ tell.push((f.buyer, "orders"));
603
+ tell.push((f.seller, "orders"));
604
+ self.last.insert(p.item.clone(), f.price);
605
+ }
606
+ self.orders.retain(|_, o| o.open > 0);
607
+ self.dirty = true;
608
+ Ok((
609
+ encode(&Placed { order: id, fills: fills.iter().map(|f| (f.price, f.count)).collect(), open: p.count - filled })?,
610
+ tell,
611
+ ))
612
+ }
613
+ "cancel" => {
614
+ let Cancel { order } = kit::args(call)?;
615
+ let meta = self.orders.get(&order).filter(|o| o.player == player).cloned();
616
+ let meta = meta.ok_or_else(|| kit::error(codes::NOT_FOUND, format!("you have no open order {order}")))?;
617
+ if let Some(book) = self.books.get_mut(&meta.item) {
618
+ book.cancel(order, player);
619
+ }
620
+ self.orders.remove(&order);
621
+ self.steps.push_back(Step::Release { player, r#ref: format!("{}:r", meta.hold), hold: meta.hold, trade: None });
622
+ self.dirty = true;
623
+ Ok((encode(&true)?, vec![(player, "orders")]))
624
+ }
625
+ "trade" => {
626
+ let With { with } = kit::args(call)?;
627
+ if with == player {
628
+ return Err(kit::error(codes::BAD_REQUEST, "a trade is between two players"));
629
+ }
630
+ self.next_id += 1;
631
+ let id = self.next_id;
632
+ let trade = Trade {
633
+ id,
634
+ players: [player, with],
635
+ sides: Default::default(),
636
+ state: TradeState::Open,
637
+ expires_at: now + self.config.trade_ms,
638
+ offers: 0,
639
+ };
640
+ self.trades.insert(id, trade);
641
+ self.dirty = true;
642
+ Ok((encode(&id)?, vec![(player, "trades"), (with, "trades")]))
643
+ }
644
+ "offer" => {
645
+ let o: OfferArgs = kit::args(call)?;
646
+ let (side, others) = self.open_trade(o.trade, player)?;
647
+ let previous = self.trades[&o.trade].sides[side].hold.clone();
648
+ let hold = if o.bundle.is_empty() {
649
+ None
650
+ } else {
651
+ Some(self.change(ctx, call, player, &format!("t{}", o.trade), "hold", json!({ "bundle": o.bundle }))?.0)
652
+ };
653
+ if let Some(h) = previous {
654
+ self.steps.push_back(Step::Release { player, r#ref: format!("{h}:r"), hold: h, trade: None });
655
+ }
656
+ let t = self.trades.get_mut(&o.trade).expect("an open trade");
657
+ t.offers += 1;
658
+ t.sides[side] = Side_ { bundle: o.bundle.clone().trimmed(), hold, accepted: false };
659
+ t.sides[1 - side].accepted = false;
660
+ Ok((encode(&true)?, vec![(player, "trades"), (others, "trades")]))
661
+ }
662
+ "accept" => {
663
+ let TradeArgs { trade } = kit::args(call)?;
664
+ let (side, others) = self.open_trade(trade, player)?;
665
+ let t = self.trades.get_mut(&trade).expect("an open trade");
666
+ if t.sides.iter().all(|s| s.bundle.is_empty()) {
667
+ return Err(kit::error(codes::NOT_READY, "nothing is offered yet"));
668
+ }
669
+ t.sides[side].accepted = true;
670
+ if t.sides.iter().all(|s| s.accepted) {
671
+ t.state = TradeState::Settling;
672
+ let t = t.clone();
673
+ for (i, s) in t.sides.iter().enumerate() {
674
+ if let Some(hold) = &s.hold {
675
+ let r#ref = self.fresh(ctx, "x");
676
+ self.steps.push_back(Step::Transfer {
677
+ from: t.players[i],
678
+ to: t.players[1 - i],
679
+ hold: hold.clone(),
680
+ bundle: s.bundle.clone(),
681
+ r#ref,
682
+ settled: false,
683
+ trade: Some(t.id),
684
+ });
685
+ }
686
+ }
687
+ }
688
+ self.dirty = true;
689
+ Ok((encode(&self.trades[&trade].state)?, vec![(player, "trades"), (others, "trades")]))
690
+ }
691
+ "decline" => {
692
+ let TradeArgs { trade } = kit::args(call)?;
693
+ let (_, others) = self.open_trade(trade, player)?;
694
+ self.call_off(trade);
695
+ Ok((encode(&true)?, vec![(player, "trades"), (others, "trades")]))
696
+ }
697
+ other => Err(Error::unknown_method(other)),
698
+ }
699
+ }
700
+
701
+ /// The caller's side of open trade `id`, and the other player.
702
+ fn open_trade(&self, id: u64, player: u64) -> Result<(usize, u64)> {
703
+ let t = self.trades.get(&id).ok_or_else(|| kit::error(codes::NOT_FOUND, format!("there is no trade {id}")))?;
704
+ let side = t.side(player).ok_or_else(|| kit::error(codes::FORBIDDEN, "it is not your trade"))?;
705
+ if t.state != TradeState::Open {
706
+ return Err(kit::error(codes::FINISHED, "the trade is settled or called off"));
707
+ }
708
+ Ok((side, t.players[1 - side]))
709
+ }
710
+
711
+ fn call_off(&mut self, id: u64) {
712
+ let Some(t) = self.trades.get_mut(&id) else { return };
713
+ t.state = TradeState::Cancelled;
714
+ let t = t.clone();
715
+ for (i, s) in t.sides.iter().enumerate() {
716
+ if let Some(hold) = &s.hold {
717
+ self.steps.push_back(Step::Release {
718
+ player: t.players[i],
719
+ r#ref: format!("{hold}:r"),
720
+ hold: hold.clone(),
721
+ trade: Some(id),
722
+ });
723
+ }
724
+ }
725
+ self.dirty = true;
726
+ }
727
+
728
+ /// Expires trades, and forgets changes whose answer was lost, giving back the holds among them.
729
+ fn sweep(&mut self, now: u64) {
730
+ let expired: Vec<u64> = self.trades.values().filter(|t| t.state == TradeState::Open && now >= t.expires_at).map(|t| t.id).collect();
731
+ for id in expired {
732
+ self.call_off(id);
733
+ }
734
+ let stale: Vec<String> =
735
+ self.unknown.iter().filter(|(_, u)| now.saturating_sub(u.at) >= PENDING_MS).map(|(r, _)| r.clone()).collect();
736
+ for r in stale {
737
+ let u = self.unknown.remove(&r).expect("a stale change");
738
+ if u.method == "hold" {
739
+ self.steps.push_back(Step::Release { player: u.player, r#ref: format!("{r}:r"), hold: r, trade: None });
740
+ }
741
+ }
742
+ self.trades.retain(|_, t| matches!(t.state, TradeState::Open | TradeState::Settling) || now < t.expires_at + self.config.trade_ms);
743
+ }
744
+ }
745
+
746
+ #[derive(Serialize, Deserialize, Default, Debug)]
747
+ pub struct MarketHub {
748
+ #[serde(flatten)]
749
+ pub market: Market,
750
+ #[serde(default)]
751
+ pub kit: Publisher,
752
+ #[serde(default)]
753
+ pub receipts: Receipts,
754
+ }
755
+
756
+ impl MarketHub {
757
+ fn arm(&self, ctx: &Ctx) -> Result<()> {
758
+ for v in &self.market.config.vendors {
759
+ if let Some(expr) = &v.restock {
760
+ ctx.timer_cron(&format!("restock:{}", v.id), &Cron::parse(expr).map_err(|e| kit::error(codes::BAD_REQUEST, e))?)?;
761
+ }
762
+ }
763
+ Ok(())
764
+ }
765
+
766
+ fn tell(&mut self, ctx: &Ctx, tell: &[(u64, &'static str)]) -> Result<()> {
767
+ let mut by_what: BTreeMap<&str, Vec<u64>> = BTreeMap::new();
768
+ for (p, what) in tell {
769
+ let who = by_what.entry(what).or_default();
770
+ if !who.contains(p) {
771
+ who.push(*p);
772
+ }
773
+ }
774
+ for (what, who) in by_what {
775
+ self.kit.changed(ctx, TOPIC, &who, what)?;
776
+ }
777
+ Ok(())
778
+ }
779
+ }
780
+
781
+ impl Hub for MarketHub {
782
+ fn spawn(ctx: &Ctx, seed: &[u8]) -> Result<Self> {
783
+ if ctx.key.len() > KEY_MAX {
784
+ return Err(kit::error(codes::BAD_REQUEST, format!("a market's key is at most {KEY_MAX} bytes")));
785
+ }
786
+ let config = if seed.is_empty() {
787
+ Config::default()
788
+ } else {
789
+ serde_json::from_slice(seed).map_err(|e| Error::new(format!("seed is not a JSON config: {e}")))?
790
+ };
791
+ let hub = MarketHub { market: Market::new(config, ctx.now_ms()), ..MarketHub::default() };
792
+ hub.arm(ctx)?;
793
+ ctx.timer_every("publish", PUBLISH_MS)?;
794
+ ctx.timer_every("steps", STEPS_MS)?;
795
+ Ok(hub)
796
+ }
797
+
798
+ fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
799
+ decode(snapshot)
800
+ }
801
+
802
+ fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
803
+ encode(self)
804
+ }
805
+
806
+ fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
807
+ let now = ctx.now_ms();
808
+ let m = &self.market;
809
+ match call.method {
810
+ "state" => return encode(&m.view()),
811
+ "vendors" => return encode(&m.vendors()),
812
+ "book" => {
813
+ let Item { item } = kit::args(&call)?;
814
+ return encode(&m.book(&item));
815
+ }
816
+ "orders" => return encode(&m.orders_of(call.player()?)),
817
+ "trades" => return encode(&m.trades_of(call.player()?)),
818
+ kit::SNAPSHOT_METHOD => return self.kit.snapshot_reply(ctx, TOPIC, &m.view()),
819
+ _ => {}
820
+ }
821
+ let mut tell = Vec::new();
822
+ let reply = Receipts::run(
823
+ self,
824
+ |h| &mut h.receipts,
825
+ &call,
826
+ now,
827
+ |h| {
828
+ let (reply, t) = h.market.act(ctx, &call, now)?;
829
+ tell = t;
830
+ Ok(reply)
831
+ },
832
+ )?;
833
+ self.market.drive(ctx, STEPS_PER_CALL)?;
834
+ self.tell(ctx, &tell)?;
835
+ Ok(reply)
836
+ }
837
+
838
+ fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
839
+ let now = ctx.now_ms();
840
+ if let Some(id) = timer.strip_prefix("restock:") {
841
+ if let Some(v) = self.market.config.vendors.iter().find(|v| v.id == id).cloned() {
842
+ self.market.shelves.entry(v.id.clone()).or_default().restock(&v, now);
843
+ self.market.dirty = true;
844
+ }
845
+ return Ok(());
846
+ }
847
+ match timer {
848
+ "steps" => {
849
+ self.market.sweep(now);
850
+ if !self.market.steps.is_empty() {
851
+ self.market.drive(ctx, STEPS_PER_CALL * 4)?;
852
+ }
853
+ }
854
+ "publish" if self.market.dirty => {
855
+ self.market.dirty = false;
856
+ self.kit.snapshot(ctx, TOPIC, &self.market.view())?;
857
+ }
858
+ _ => {}
859
+ }
860
+ Ok(())
861
+ }
862
+ }
863
+
864
+ ckx_sdk::export_hub!(MarketHub);
865
+
866
+ #[cfg(test)]
867
+ mod tests {
868
+ use super::*;
869
+ use ckx_sdk::CallError;
870
+ use ckx_sdk::kit::econ::items::{Catalog, ItemDef};
871
+ use ckx_sdk::kit::econ::{Applied, Holdings, Holds};
872
+ use ckx_sdk::kit::error_code;
873
+ use ckx_sdk::testing::{Message as Sent, TestHub};
874
+ use std::cell::RefCell;
875
+ use std::rc::Rc;
876
+
877
+ /// The players' hubs as the market sees them: holdings, holds and the references answered.
878
+ #[derive(Default)]
879
+ struct Players {
880
+ catalog: Catalog,
881
+ of: BTreeMap<u64, (Holdings, Holds)>,
882
+ applied: Applied,
883
+ /// Players whose hub does not answer.
884
+ down: BTreeSet<u64>,
885
+ /// Players whose hub runs what it is asked, but whose answer is lost.
886
+ lost: BTreeSet<u64>,
887
+ }
888
+
889
+ impl Players {
890
+ fn new(players: &[(u64, Bundle)]) -> Rc<RefCell<Self>> {
891
+ let catalog = Catalog::from(
892
+ ["wood", "plank", "ore", "ingot", "potion"]
893
+ .iter()
894
+ .map(|i| ItemDef { id: (*i).into(), max_stack: 99, tradable: true, name: String::new() })
895
+ .chain(std::iter::once(ItemDef { id: "sword".into(), max_stack: 1, tradable: true, name: String::new() }))
896
+ .collect::<Vec<_>>(),
897
+ );
898
+ let of = players
899
+ .iter()
900
+ .map(|(p, b)| {
901
+ let mut h = Holdings::new(20);
902
+ h.give(&catalog, b).unwrap();
903
+ (*p, (h, Holds::default()))
904
+ })
905
+ .collect();
906
+ Rc::new(RefCell::new(Players { catalog, of, ..Players::default() }))
907
+ }
908
+
909
+ fn answer(&mut self, m: &Sent) -> std::result::Result<Vec<u8>, CallError> {
910
+ let player: u64 = m.key.parse().unwrap();
911
+ if self.down.contains(&player) {
912
+ return Err(CallError { status: Status::DeadlineExceeded, message: "no reply".into() });
913
+ }
914
+ let args: Value = decode(&m.payload).unwrap();
915
+ let reference = args["ref"].as_str().unwrap().to_owned();
916
+ if let Some(replayed) = self.applied.replay(&reference, 0) {
917
+ return replayed.map_err(|e| CallError { status: Status::AppError, message: e.0 });
918
+ }
919
+ let catalog = self.catalog.clone();
920
+ let (holdings, holds) = self.of.get_mut(&player).unwrap();
921
+ let bundle = |k: &str| -> Bundle { serde_json::from_value(args[k].clone()).unwrap_or_default() };
922
+ let result: Result<Vec<u8>> = (|| match m.method.as_str() {
923
+ "hold" => holds.hold(holdings, &reference, &bundle("bundle")).and_then(|_| encode(&true)),
924
+ "settle" => holds.settle(args["hold"].as_str().unwrap(), &bundle("bundle")).and_then(|_| encode(&true)),
925
+ "release" => encode(&holds.release(holdings, &catalog, args["hold"].as_str().unwrap())?),
926
+ "deposit" => holdings.give_or_mail(&catalog, &bundle("bundle")).and_then(|_| encode(&true)),
927
+ "exchange" => holdings.swap(&catalog, &bundle("pay"), &bundle("get")).and_then(|_| encode(&true)),
928
+ other => panic!("the market asked for {other}"),
929
+ })();
930
+ self.applied.record(&reference, &result, 0);
931
+ if self.lost.contains(&player) {
932
+ return Err(CallError { status: Status::DeadlineExceeded, message: "the answer was lost".into() });
933
+ }
934
+ result.map_err(|e| CallError { status: Status::AppError, message: e.0 })
935
+ }
936
+
937
+ fn gold(&self, p: u64) -> i64 {
938
+ self.of[&p].0.wallet.balance("gold")
939
+ }
940
+
941
+ fn count(&self, p: u64, item: &str) -> u64 {
942
+ self.of[&p].0.inventory.count(item)
943
+ }
944
+ }
945
+
946
+ fn market(players: &Rc<RefCell<Players>>) -> TestHub<MarketHub> {
947
+ let hub = TestHub::<MarketHub>::spawn("market", "town").unwrap();
948
+ let p = players.clone();
949
+ hub.answer_calls("player", move |m| p.borrow_mut().answer(m));
950
+ hub
951
+ }
952
+
953
+ fn code(e: &CallError) -> Option<&str> {
954
+ error_code(&e.message).map(|c| c.0)
955
+ }
956
+
957
+ #[test]
958
+ fn a_vendor_sells_from_its_stock_once_a_player_and_buys_back() {
959
+ let players = Players::new(&[(1, Bundle::coins("gold", 120)), (2, Bundle::coins("gold", 10))]);
960
+ let mut m = market(&players);
961
+ let got: Bundle =
962
+ m.call_typed(Caller::Player(1), "buy", &json!({ "vendor": "smith", "item": "sword", "action_id": "b1" })).unwrap();
963
+ assert_eq!(got, Bundle::item("sword", 1));
964
+ let _: Bundle = m.call_typed(Caller::Player(1), "buy", &json!({ "vendor": "smith", "item": "sword", "action_id": "b1" })).unwrap();
965
+ assert_eq!(players.borrow().gold(1), 70, "a resent buy is answered, not bought again");
966
+ let e = m.call(Caller::Player(1), "buy", &encode(&json!({ "vendor": "smith", "item": "sword" })).unwrap()).unwrap_err();
967
+ assert_eq!(code(&e), Some(codes::LIMIT), "one a player until the restock");
968
+ let e = m.call(Caller::Player(2), "buy", &encode(&json!({ "vendor": "smith", "item": "sword" })).unwrap()).unwrap_err();
969
+ assert_eq!(code(&e), Some(codes::INSUFFICIENT), "player 2 has 10 gold");
970
+ let paid: Bundle = m.call_typed(Caller::Player(1), "sell", &json!({ "vendor": "smith", "item": "sword" })).unwrap();
971
+ assert_eq!(paid, Bundle::coins("gold", 20));
972
+ let v: Vec<VendorView> = m.call_typed(Caller::Player(2), "vendors", &()).unwrap();
973
+ assert_eq!(v[0].offers[0].stock, Some(4));
974
+ m.advance(3_600_000).unwrap();
975
+ let v: Vec<VendorView> = m.call_typed(Caller::Player(2), "vendors", &()).unwrap();
976
+ assert_eq!(v[0].offers[0].stock, Some(5), "restocked on the hour");
977
+ }
978
+
979
+ #[test]
980
+ fn orders_hold_what_they_offer_and_a_fill_moves_both_sides_once() {
981
+ let players = Players::new(&[(1, Bundle::coins("gold", 100)), (2, Bundle::item("ore", 10))]);
982
+ let mut m = market(&players);
983
+ let sell: Placed = m
984
+ .call_typed(Caller::Player(2), "order", &json!({ "item": "ore", "side": "sell", "price": 4, "count": 6, "action_id": "s1" }))
985
+ .unwrap();
986
+ assert_eq!((sell.open, players.borrow().count(2, "ore")), (6, 4), "the ore is held");
987
+ let buy: Placed = m
988
+ .call_typed(Caller::Player(1), "order", &json!({ "item": "ore", "side": "buy", "price": 5, "count": 4, "action_id": "o1" }))
989
+ .unwrap();
990
+ assert_eq!((buy.fills.clone(), buy.open), (vec![(4, 4)], 0), "at the resting price");
991
+ let p = players.borrow();
992
+ assert_eq!((p.count(1, "ore"), p.gold(1)), (4, 84), "16 paid; the 4 held above it came back");
993
+ assert_eq!(p.gold(2), 16);
994
+ drop(p);
995
+ let book: BookView = m.call_typed(Caller::Player(1), "book", &json!({ "item": "ore" })).unwrap();
996
+ assert_eq!((book.asks.clone(), book.last), (vec![(4, 2)], Some(4)));
997
+ let mine: Vec<OrderMeta> = m.call_typed(Caller::Player(2), "orders", &()).unwrap();
998
+ let _: bool = m.call_typed(Caller::Player(2), "cancel", &json!({ "order": mine[0].id })).unwrap();
999
+ assert_eq!(players.borrow().count(2, "ore"), 6, "the rest comes back");
1000
+ let e = m
1001
+ .call(Caller::Player(1), "order", &encode(&json!({ "item": "ore", "side": "buy", "price": 5, "count": 100 })).unwrap())
1002
+ .unwrap_err();
1003
+ assert_eq!(code(&e), Some(codes::INSUFFICIENT), "a buy holds its price");
1004
+ }
1005
+
1006
+ #[test]
1007
+ fn a_player_whose_hub_does_not_answer_gets_unavailable_and_a_resend_finishes() {
1008
+ let players = Players::new(&[(1, Bundle::coins("gold", 100)), (2, Bundle::item("ore", 10))]);
1009
+ let mut m = market(&players);
1010
+ let _: Placed = m
1011
+ .call_typed(Caller::Player(2), "order", &json!({ "item": "ore", "side": "sell", "price": 4, "count": 5, "action_id": "s1" }))
1012
+ .unwrap();
1013
+ players.borrow_mut().down.insert(2);
1014
+ let fill: Placed = m
1015
+ .call_typed(Caller::Player(1), "order", &json!({ "item": "ore", "side": "buy", "price": 4, "count": 5, "action_id": "o1" }))
1016
+ .unwrap();
1017
+ assert_eq!(fill.open, 0);
1018
+ assert_eq!(players.borrow().count(1, "ore"), 0, "the seller's hub must give the ore up first");
1019
+ players.borrow_mut().down.remove(&2);
1020
+ m.advance(STEPS_MS).unwrap();
1021
+ assert_eq!((players.borrow().count(1, "ore"), players.borrow().gold(2)), (5, 20), "the steps finish once it answers");
1022
+ players.borrow_mut().down.insert(1);
1023
+ let e = m
1024
+ .call(Caller::Player(1), "buy", &encode(&json!({ "vendor": "smith", "item": "potion", "action_id": "b9" })).unwrap())
1025
+ .unwrap_err();
1026
+ assert_eq!(code(&e), Some(codes::UNAVAILABLE));
1027
+ players.borrow_mut().down.remove(&1);
1028
+ let got: Bundle =
1029
+ m.call_typed(Caller::Player(1), "buy", &json!({ "vendor": "smith", "item": "potion", "action_id": "b9" })).unwrap();
1030
+ assert_eq!(got, Bundle::item("potion", 1), "the resend runs: unavailable was not a receipt");
1031
+ }
1032
+
1033
+ #[test]
1034
+ fn a_trade_settles_when_both_accept_the_last_offers_and_a_declined_one_gives_back() {
1035
+ let players = Players::new(&[(1, Bundle::coins("gold", 50)), (2, Bundle::item("plank", 5))]);
1036
+ let mut m = market(&players);
1037
+ let t: u64 = m.call_typed(Caller::Player(1), "trade", &json!({ "with": 2 })).unwrap();
1038
+ let _: bool = m
1039
+ .call_typed(Caller::Player(1), "offer", &json!({ "trade": t, "bundle": { "currency": { "gold": 30 } }, "action_id": "a" }))
1040
+ .unwrap();
1041
+ let _: bool = m
1042
+ .call_typed(Caller::Player(2), "offer", &json!({ "trade": t, "bundle": { "items": { "plank": 5 } }, "action_id": "b" }))
1043
+ .unwrap();
1044
+ let _: TradeState = m.call_typed(Caller::Player(1), "accept", &json!({ "trade": t })).unwrap();
1045
+ let _: bool = m
1046
+ .call_typed(Caller::Player(1), "offer", &json!({ "trade": t, "bundle": { "currency": { "gold": 20 } }, "action_id": "a2" }))
1047
+ .unwrap();
1048
+ let state: TradeState = m.call_typed(Caller::Player(2), "accept", &json!({ "trade": t })).unwrap();
1049
+ assert_eq!(state, TradeState::Open, "an offer changed: player 1 accepts again");
1050
+ let state: TradeState = m.call_typed(Caller::Player(1), "accept", &json!({ "trade": t })).unwrap();
1051
+ assert_eq!(state, TradeState::Settling);
1052
+ let p = players.borrow();
1053
+ assert_eq!((p.gold(1), p.count(1, "plank"), p.gold(2), p.count(2, "plank")), (30, 5, 20, 0));
1054
+ drop(p);
1055
+ let trades: Vec<Trade> = m.call_typed(Caller::Player(1), "trades", &()).unwrap();
1056
+ assert!(trades.is_empty(), "settled");
1057
+ let t2: u64 = m.call_typed(Caller::Player(2), "trade", &json!({ "with": 1 })).unwrap();
1058
+ let _: bool = m
1059
+ .call_typed(Caller::Player(1), "offer", &json!({ "trade": t2, "bundle": { "currency": { "gold": 10 } }, "action_id": "c" }))
1060
+ .unwrap();
1061
+ assert_eq!(players.borrow().gold(1), 20);
1062
+ let e = m.call(Caller::Player(3), "decline", &encode(&json!({ "trade": t2 })).unwrap()).unwrap_err();
1063
+ assert_eq!(code(&e), Some(codes::FORBIDDEN));
1064
+ let _: bool = m.call_typed(Caller::Player(2), "decline", &json!({ "trade": t2 })).unwrap();
1065
+ assert_eq!(players.borrow().gold(1), 30, "the hold comes back");
1066
+ }
1067
+
1068
+ #[test]
1069
+ fn a_hold_whose_answer_was_lost_is_given_back_and_a_late_resend_is_a_new_order() {
1070
+ let players = Players::new(&[(1, Bundle::coins("gold", 100))]);
1071
+ let mut m = market(&players);
1072
+ let order = json!({ "item": "ore", "side": "buy", "price": 5, "count": 2, "action_id": "p1" });
1073
+ players.borrow_mut().lost.insert(1);
1074
+ let e = m.call(Caller::Player(1), "order", &encode(&order).unwrap()).unwrap_err();
1075
+ assert_eq!(code(&e), Some(codes::UNAVAILABLE));
1076
+ assert_eq!(players.borrow().gold(1), 90, "it ran; only its answer was lost");
1077
+ players.borrow_mut().lost.remove(&1);
1078
+ m.advance(PENDING_MS + STEPS_MS).unwrap();
1079
+ assert_eq!(players.borrow().gold(1), 100, "given back");
1080
+ let placed: Placed = m.call_typed(Caller::Player(1), "order", &order).unwrap();
1081
+ assert_eq!((placed.open, players.borrow().gold(1)), (2, 90), "a late resend is a new order, held anew");
1082
+ }
1083
+
1084
+ #[test]
1085
+ fn a_resend_after_a_lost_answer_asks_again_by_the_same_reference() {
1086
+ let players = Players::new(&[(1, Bundle::coins("gold", 100))]);
1087
+ let mut m = market(&players);
1088
+ let order = |count: u64, action: &str| json!({ "item": "ore", "side": "buy", "price": 5, "count": count, "action_id": action });
1089
+ players.borrow_mut().lost.insert(1);
1090
+ assert!(m.call(Caller::Player(1), "order", &encode(&order(2, "q1")).unwrap()).is_err());
1091
+ assert!(m.call(Caller::Player(1), "order", &encode(&order(2, "q2")).unwrap()).is_err());
1092
+ players.borrow_mut().lost.remove(&1);
1093
+ assert_eq!(players.borrow().gold(1), 80);
1094
+ let placed: Placed = m.call_typed(Caller::Player(1), "order", &order(2, "q1")).unwrap();
1095
+ assert_eq!((placed.open, players.borrow().gold(1)), (2, 80), "held once");
1096
+ let e = m.call(Caller::Player(1), "order", &encode(&order(3, "q2")).unwrap()).unwrap_err();
1097
+ assert_eq!(code(&e), Some(codes::CONFLICT), "the same action_id for another order");
1098
+ m.advance(PENDING_MS + STEPS_MS).unwrap();
1099
+ assert_eq!(players.borrow().gold(1), 90, "the hold nobody finished comes back");
1100
+ let mine: Vec<OrderMeta> = m.call_typed(Caller::Player(1), "orders", &()).unwrap();
1101
+ assert_eq!(mine.len(), 1);
1102
+ assert!(m.hub().market.unknown.is_empty());
1103
+ }
1104
+
1105
+ #[test]
1106
+ fn an_action_id_never_makes_a_hold_that_holds_nothing() {
1107
+ // Ids as long as the platform's, an action id used again once the market's receipt of it
1108
+ // is gone, and one shaped like a step's suffix.
1109
+ let (a, b) = (101_910_912_345_678_u64, 101_910_912_345_679_u64);
1110
+ let players = Players::new(&[(a, Bundle::coins("gold", 100)), (b, Bundle::item("potion", 3))]);
1111
+ let mut m = market(&players);
1112
+ let id = "3f2c9a1e-8b7d-4e6f-9a0b-1c2d3e4f5a6b";
1113
+ let order = |action: &str| json!({ "item": "ore", "side": "buy", "price": 5, "count": 2, "action_id": action });
1114
+ let first: Placed = m.call_typed(Caller::Player(a), "order", &order(id)).unwrap();
1115
+ let _: bool = m.call_typed(Caller::Player(a), "cancel", &json!({ "order": first.order })).unwrap();
1116
+ m.advance(kit::RECEIPT_TTL_MS + STEPS_MS).unwrap();
1117
+ let _: Placed = m.call_typed(Caller::Player(a), "order", &order(id)).unwrap();
1118
+ let _: Placed = m.call_typed(Caller::Player(a), "order", &order(&format!("{id}:r"))).unwrap();
1119
+ assert_eq!(players.borrow().gold(a), 80, "each order holds its own 10");
1120
+ let t: u64 = m.call_typed(Caller::Player(a), "trade", &json!({ "with": b })).unwrap();
1121
+ let offer = |bundle: Value, action: &str| json!({ "trade": t, "bundle": bundle, "action_id": action });
1122
+ let gold = offer(json!({ "currency": { "gold": 10 } }), "9d1e7c3b-2a4f-4b6e-8c0d-5e6f7a8b9c0d");
1123
+ let _: bool = m.call_typed(Caller::Player(a), "offer", &gold).unwrap();
1124
+ let _: bool = m.call_typed(Caller::Player(b), "offer", &offer(json!({ "items": { "potion": 1 } }), id)).unwrap();
1125
+ let _: TradeState = m.call_typed(Caller::Player(a), "accept", &json!({ "trade": t })).unwrap();
1126
+ let _: TradeState = m.call_typed(Caller::Player(b), "accept", &json!({ "trade": t })).unwrap();
1127
+ let p = players.borrow();
1128
+ assert_eq!((p.gold(a), p.count(a, "potion"), p.gold(b), p.count(b, "potion")), (70, 1, 10, 2), "both sides moved");
1129
+ assert_eq!(m.hub().market.stuck, 0);
1130
+ }
1131
+
1132
+ #[test]
1133
+ fn a_market_key_fits_its_references() {
1134
+ assert!(TestHub::<MarketHub>::spawn("market", &"k".repeat(KEY_MAX)).is_ok());
1135
+ assert!(TestHub::<MarketHub>::spawn("market", &"k".repeat(KEY_MAX + 1)).is_err());
1136
+ }
1137
+ }