@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,254 @@
1
+ //! Levels from experience ([`Levels`], [`Progress`]); achievements and quests that count what a
2
+ //! player did ([`AchievementDef`], [`QuestDef`], [`Record`]).
3
+ //!
4
+ //! What a player did reaches the record as counters (`wins`, `kill:wolf`, `crafted:plank`).
5
+ //! A hub that decided it reports it; a counter a game lets the client report itself (a
6
+ //! tutorial step) is for things worth nothing.
7
+
8
+ use std::collections::{BTreeMap, BTreeSet};
9
+
10
+ use serde::{Deserialize, Serialize};
11
+
12
+ use super::holdings::Bundle;
13
+ use crate::Result;
14
+ use crate::kit::{codes, error};
15
+
16
+ /// Experience to go from level `n` to `n + 1` is `base * growth^(n - 1)`, up to `max`.
17
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
18
+ #[serde(default)]
19
+ pub struct Levels {
20
+ pub base: u64,
21
+ pub growth: f64,
22
+ pub max: u32,
23
+ }
24
+
25
+ impl Default for Levels {
26
+ fn default() -> Self {
27
+ Self { base: 100, growth: 1.5, max: 50 }
28
+ }
29
+ }
30
+
31
+ impl Levels {
32
+ /// What level `level` takes to finish; `None` at the top.
33
+ pub fn to_next(&self, level: u32) -> Option<u64> {
34
+ if level >= self.max {
35
+ return None;
36
+ }
37
+ let need = self.base as f64 * self.growth.max(1.0).powi(level.saturating_sub(1) as i32);
38
+ Some(need.min(u64::MAX as f64).round().max(1.0) as u64)
39
+ }
40
+ }
41
+
42
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
43
+ #[serde(default)]
44
+ pub struct Progress {
45
+ pub level: u32,
46
+ /// Toward the next level.
47
+ pub xp: u64,
48
+ pub total_xp: u64,
49
+ }
50
+
51
+ impl Default for Progress {
52
+ fn default() -> Self {
53
+ Self { level: 1, xp: 0, total_xp: 0 }
54
+ }
55
+ }
56
+
57
+ impl Progress {
58
+ /// Adds experience; answers how many levels it gained.
59
+ pub fn gain(&mut self, levels: &Levels, xp: u64) -> u32 {
60
+ self.total_xp = self.total_xp.saturating_add(xp);
61
+ self.xp = self.xp.saturating_add(xp);
62
+ let mut gained = 0;
63
+ while let Some(need) = levels.to_next(self.level) {
64
+ if self.xp < need {
65
+ break;
66
+ }
67
+ self.xp -= need;
68
+ self.level += 1;
69
+ gained += 1;
70
+ }
71
+ if levels.to_next(self.level).is_none() {
72
+ self.xp = 0;
73
+ }
74
+ gained
75
+ }
76
+ }
77
+
78
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
79
+ pub struct AchievementDef {
80
+ pub id: String,
81
+ pub counter: String,
82
+ pub target: u64,
83
+ #[serde(default)]
84
+ pub reward: Bundle,
85
+ #[serde(default)]
86
+ pub xp: u64,
87
+ /// Not shown until unlocked.
88
+ #[serde(default)]
89
+ pub hidden: bool,
90
+ }
91
+
92
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
93
+ pub struct Objective {
94
+ pub counter: String,
95
+ pub target: u64,
96
+ }
97
+
98
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
99
+ pub struct QuestDef {
100
+ pub id: String,
101
+ pub objectives: Vec<Objective>,
102
+ #[serde(default)]
103
+ pub reward: Bundle,
104
+ #[serde(default)]
105
+ pub xp: u64,
106
+ /// Quests done first.
107
+ #[serde(default)]
108
+ pub requires: Vec<String>,
109
+ #[serde(default)]
110
+ pub level: u32,
111
+ /// A first-session tutorial: taken on as the player starts.
112
+ #[serde(default)]
113
+ pub tutorial: bool,
114
+ }
115
+
116
+ /// What a player did and what it earned them.
117
+ #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
118
+ #[serde(default)]
119
+ pub struct Record {
120
+ pub counters: BTreeMap<String, u64>,
121
+ /// Achievement to when it unlocked.
122
+ pub unlocked: BTreeMap<String, u64>,
123
+ /// Quest taken on, to each objective's count since.
124
+ pub active: BTreeMap<String, Vec<u64>>,
125
+ /// Done, waiting for its reward to be claimed.
126
+ pub complete: BTreeSet<String>,
127
+ /// Claimed, to when.
128
+ pub done: BTreeMap<String, u64>,
129
+ }
130
+
131
+ /// What one count changed.
132
+ #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
133
+ #[serde(default)]
134
+ pub struct Unlocks {
135
+ pub achievements: Vec<String>,
136
+ pub quests: Vec<String>,
137
+ }
138
+
139
+ impl Record {
140
+ /// Counts `n` of `counter`: the achievements it unlocks and the quests it completes.
141
+ pub fn count(&mut self, achievements: &[AchievementDef], quests: &[QuestDef], counter: &str, n: u64, now: u64) -> Unlocks {
142
+ let mut out = Unlocks::default();
143
+ let total = self.counters.entry(counter.to_owned()).or_insert(0);
144
+ *total = total.saturating_add(n);
145
+ let total = *total;
146
+ for a in achievements.iter().filter(|a| a.counter == counter && total >= a.target) {
147
+ if !self.unlocked.contains_key(&a.id) {
148
+ self.unlocked.insert(a.id.clone(), now);
149
+ out.achievements.push(a.id.clone());
150
+ }
151
+ }
152
+ for q in quests {
153
+ let Some(counts) = self.active.get_mut(&q.id) else { continue };
154
+ for (i, o) in q.objectives.iter().enumerate() {
155
+ if o.counter == counter
156
+ && let Some(c) = counts.get_mut(i)
157
+ {
158
+ *c = (*c).saturating_add(n).min(o.target);
159
+ }
160
+ }
161
+ if q.objectives.iter().zip(counts.iter()).all(|(o, c)| *c >= o.target) {
162
+ self.active.remove(&q.id);
163
+ self.complete.insert(q.id.clone());
164
+ out.quests.push(q.id.clone());
165
+ }
166
+ }
167
+ out
168
+ }
169
+
170
+ /// Takes on quest `q`: `conflict` when it is taken, done or waits for another, `forbidden`
171
+ /// below its level.
172
+ pub fn accept(&mut self, q: &QuestDef, level: u32) -> Result<()> {
173
+ if self.active.contains_key(&q.id) || self.complete.contains(&q.id) || self.done.contains_key(&q.id) {
174
+ return Err(error(codes::CONFLICT, format!("`{}` is taken or done", q.id)));
175
+ }
176
+ if let Some(missing) = q.requires.iter().find(|r| !self.done.contains_key(*r)) {
177
+ return Err(error(codes::CONFLICT, format!("`{}` comes after `{missing}`", q.id)));
178
+ }
179
+ if level < q.level {
180
+ return Err(error(codes::FORBIDDEN, format!("`{}` needs level {}", q.id, q.level)));
181
+ }
182
+ self.active.insert(q.id.clone(), vec![0; q.objectives.len()]);
183
+ Ok(())
184
+ }
185
+
186
+ /// Claims quest `id`'s reward: `not_ready` until it is complete.
187
+ pub fn claim(&mut self, id: &str, now: u64) -> Result<()> {
188
+ if !self.complete.remove(id) {
189
+ return Err(error(codes::NOT_READY, format!("`{id}` is not complete")));
190
+ }
191
+ self.done.insert(id.to_owned(), now);
192
+ Ok(())
193
+ }
194
+ }
195
+
196
+ #[cfg(test)]
197
+ mod tests {
198
+ use super::*;
199
+
200
+ #[test]
201
+ fn experience_levels_up_along_the_curve_and_stops_at_the_top() {
202
+ let levels = Levels { base: 100, growth: 2.0, max: 3 };
203
+ let mut p = Progress::default();
204
+ assert_eq!(p.gain(&levels, 250), 1);
205
+ assert_eq!((p.level, p.xp), (2, 150));
206
+ assert_eq!(levels.to_next(2), Some(200));
207
+ assert_eq!(p.gain(&levels, 1_000), 1);
208
+ assert_eq!((p.level, p.xp, p.total_xp), (3, 0, 1_250), "the top keeps no progress");
209
+ }
210
+
211
+ #[test]
212
+ fn counts_unlock_achievements_once_and_complete_quests_taken_on() {
213
+ let achievements = vec![AchievementDef {
214
+ id: "lumberjack".into(),
215
+ counter: "chop".into(),
216
+ target: 3,
217
+ reward: Bundle::default(),
218
+ xp: 0,
219
+ hidden: false,
220
+ }];
221
+ let quests = vec![
222
+ QuestDef {
223
+ id: "first".into(),
224
+ objectives: vec![Objective { counter: "chop".into(), target: 2 }],
225
+ reward: Bundle::coins("gold", 5),
226
+ xp: 10,
227
+ requires: vec![],
228
+ level: 0,
229
+ tutorial: true,
230
+ },
231
+ QuestDef {
232
+ id: "second".into(),
233
+ objectives: vec![Objective { counter: "chop".into(), target: 1 }],
234
+ reward: Bundle::default(),
235
+ xp: 0,
236
+ requires: vec!["first".into()],
237
+ level: 0,
238
+ tutorial: false,
239
+ },
240
+ ];
241
+ let mut r = Record::default();
242
+ r.count(&achievements, &quests, "chop", 1, 0);
243
+ r.accept(&quests[0], 1).unwrap();
244
+ assert!(r.accept(&quests[1], 1).is_err(), "after the first");
245
+ assert_eq!(r.count(&achievements, &quests, "chop", 1, 1).quests, Vec::<String>::new(), "counts from when it was taken on");
246
+ let u = r.count(&achievements, &quests, "chop", 5, 2);
247
+ assert_eq!((u.achievements, u.quests), (vec!["lumberjack".to_string()], vec!["first".to_string()]));
248
+ assert!(r.count(&achievements, &quests, "chop", 1, 3).achievements.is_empty(), "unlocked once");
249
+ assert!(r.claim("second", 4).is_err());
250
+ r.claim("first", 4).unwrap();
251
+ assert!(r.claim("first", 5).is_err(), "claimed once");
252
+ r.accept(&quests[1], 1).unwrap();
253
+ }
254
+ }
@@ -0,0 +1,65 @@
1
+ //! Crafting: a recipe takes its inputs (items, and currency for a fee) and gives its outputs,
2
+ //! all together or not at all.
3
+
4
+ use serde::{Deserialize, Serialize};
5
+
6
+ use super::holdings::{Bundle, Holdings};
7
+ use super::items::Catalog;
8
+ use crate::Result;
9
+ use crate::kit::{codes, error};
10
+
11
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
12
+ pub struct Recipe {
13
+ pub id: String,
14
+ pub inputs: Bundle,
15
+ pub outputs: Bundle,
16
+ /// The level a player needs; 0 for none.
17
+ #[serde(default)]
18
+ pub level: u32,
19
+ }
20
+
21
+ /// The most crafts one call makes.
22
+ pub const CRAFT_MAX: u64 = 100;
23
+
24
+ /// Crafts `recipe` `times` times into `holdings`; answers what it made. `insufficient` without
25
+ /// the inputs, `full` without room for the outputs (the inputs' room counts, since they go
26
+ /// first), `forbidden` below the recipe's level.
27
+ pub fn craft(holdings: &mut Holdings, catalog: &Catalog, recipe: &Recipe, times: u64, level: u32) -> Result<Bundle> {
28
+ if times == 0 || times > CRAFT_MAX {
29
+ return Err(error(codes::BAD_REQUEST, format!("1 to {CRAFT_MAX} at a time")));
30
+ }
31
+ if level < recipe.level {
32
+ return Err(error(codes::FORBIDDEN, format!("`{}` needs level {}", recipe.id, recipe.level)));
33
+ }
34
+ let made = recipe.outputs.times(times)?;
35
+ holdings.swap(catalog, &recipe.inputs.times(times)?, &made)?;
36
+ Ok(made)
37
+ }
38
+
39
+ #[cfg(test)]
40
+ mod tests {
41
+ use super::*;
42
+ use crate::kit::econ::items::ItemDef;
43
+
44
+ #[test]
45
+ fn a_craft_takes_its_inputs_and_gives_its_outputs_or_nothing() {
46
+ let c = Catalog::from(vec![
47
+ ItemDef { id: "wood".into(), max_stack: 20, tradable: true, name: String::new() },
48
+ ItemDef { id: "plank".into(), max_stack: 20, tradable: true, name: String::new() },
49
+ ]);
50
+ let plank = Recipe {
51
+ id: "plank".into(),
52
+ inputs: Bundle::item("wood", 2).plus(&Bundle::coins("gold", 1)).unwrap(),
53
+ outputs: Bundle::item("plank", 1),
54
+ level: 0,
55
+ };
56
+ let mut h = Holdings::new(2);
57
+ h.give(&c, &Bundle::item("wood", 5).plus(&Bundle::coins("gold", 2)).unwrap()).unwrap();
58
+ assert_eq!(craft(&mut h, &c, &plank, 2, 1).unwrap(), Bundle::item("plank", 2));
59
+ assert_eq!((h.inventory.count("wood"), h.inventory.count("plank"), h.wallet.balance("gold")), (1, 2, 0));
60
+ let before = h.clone();
61
+ assert!(craft(&mut h, &c, &plank, 1, 1).is_err(), "out of wood and gold");
62
+ assert_eq!(h, before);
63
+ assert!(craft(&mut h, &c, &Recipe { level: 3, ..plank.clone() }, 1, 2).is_err());
64
+ }
65
+ }
@@ -0,0 +1,116 @@
1
+ //! Vendors: offers at fixed prices, each with stock that restocks and a limit per player.
2
+
3
+ use std::collections::BTreeMap;
4
+
5
+ use serde::{Deserialize, Serialize};
6
+
7
+ use super::holdings::Bundle;
8
+ use crate::Result;
9
+ use crate::kit::{codes, error};
10
+
11
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
12
+ pub struct Offer {
13
+ pub item: String,
14
+ /// What one costs.
15
+ pub price: Bundle,
16
+ /// How many a restock puts on the shelf; none for no limit.
17
+ #[serde(default)]
18
+ pub stock: Option<u64>,
19
+ /// How many one player may buy between restocks; none for no limit.
20
+ #[serde(default)]
21
+ pub per_player: Option<u64>,
22
+ /// What the vendor pays for one; none when it does not buy them.
23
+ #[serde(default)]
24
+ pub buys_for: Option<Bundle>,
25
+ }
26
+
27
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
28
+ pub struct Vendor {
29
+ pub id: String,
30
+ pub offers: Vec<Offer>,
31
+ /// When it restocks (cron), or never.
32
+ #[serde(default)]
33
+ pub restock: Option<String>,
34
+ }
35
+
36
+ /// A vendor's shelf and who bought what since the last restock.
37
+ #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
38
+ #[serde(default)]
39
+ pub struct Shelf {
40
+ pub stock: BTreeMap<String, u64>,
41
+ /// `"<player>:<item>"` to how many.
42
+ pub bought: BTreeMap<String, u64>,
43
+ pub restocked_at: u64,
44
+ }
45
+
46
+ impl Vendor {
47
+ pub fn offer(&self, item: &str) -> Result<&Offer> {
48
+ self.offers.iter().find(|o| o.item == item).ok_or_else(|| error(codes::NOT_FOUND, format!("`{}` sells no `{item}`", self.id)))
49
+ }
50
+ }
51
+
52
+ impl Shelf {
53
+ pub fn restock(&mut self, vendor: &Vendor, now: u64) {
54
+ self.stock = vendor.offers.iter().filter_map(|o| o.stock.map(|s| (o.item.clone(), s))).collect();
55
+ self.bought.clear();
56
+ self.restocked_at = now;
57
+ }
58
+
59
+ /// Whether `player` may buy `count` of `offer` now: `insufficient` (stock), `limit`.
60
+ pub fn check(&self, offer: &Offer, player: u64, count: u64) -> Result<()> {
61
+ if count == 0 {
62
+ return Err(error(codes::BAD_REQUEST, "buy at least one"));
63
+ }
64
+ if offer.stock.is_some() && self.stock.get(&offer.item).copied().unwrap_or(0) < count {
65
+ return Err(error(codes::INSUFFICIENT, format!("not {count} {} in stock", offer.item)));
66
+ }
67
+ if let Some(cap) = offer.per_player
68
+ && self.bought.get(&format!("{player}:{}", offer.item)).copied().unwrap_or(0) + count > cap
69
+ {
70
+ return Err(error(codes::LIMIT, format!("{cap} {} a player until the restock", offer.item)));
71
+ }
72
+ Ok(())
73
+ }
74
+
75
+ /// Records a sale `check` allowed.
76
+ pub fn sold(&mut self, offer: &Offer, player: u64, count: u64) {
77
+ if offer.stock.is_some()
78
+ && let Some(s) = self.stock.get_mut(&offer.item)
79
+ {
80
+ *s = s.saturating_sub(count);
81
+ }
82
+ *self.bought.entry(format!("{player}:{}", offer.item)).or_insert(0) += count;
83
+ }
84
+ }
85
+
86
+ #[cfg(test)]
87
+ mod tests {
88
+ use super::*;
89
+
90
+ #[test]
91
+ fn stock_and_a_per_player_limit_hold_until_the_restock() {
92
+ let v = Vendor {
93
+ id: "smith".into(),
94
+ offers: vec![Offer {
95
+ item: "sword".into(),
96
+ price: Bundle::coins("gold", 50),
97
+ stock: Some(2),
98
+ per_player: Some(1),
99
+ buys_for: None,
100
+ }],
101
+ restock: None,
102
+ };
103
+ let mut shelf = Shelf::default();
104
+ shelf.restock(&v, 1);
105
+ let o = v.offer("sword").unwrap();
106
+ shelf.check(o, 1, 1).unwrap();
107
+ shelf.sold(o, 1, 1);
108
+ assert!(shelf.check(o, 1, 1).is_err(), "one a player");
109
+ shelf.check(o, 2, 1).unwrap();
110
+ shelf.sold(o, 2, 1);
111
+ assert!(shelf.check(o, 3, 1).is_err(), "sold out");
112
+ shelf.restock(&v, 2);
113
+ shelf.check(o, 1, 1).unwrap();
114
+ assert!(v.offer("shield").is_err());
115
+ }
116
+ }
@@ -0,0 +1,67 @@
1
+ //! Soft currencies: balances that never go below zero.
2
+
3
+ use std::collections::BTreeMap;
4
+
5
+ use serde::{Deserialize, Serialize};
6
+
7
+ use crate::Result;
8
+ use crate::kit::{codes, error};
9
+
10
+ #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
11
+ pub struct Wallet {
12
+ pub balances: BTreeMap<String, i64>,
13
+ }
14
+
15
+ impl Wallet {
16
+ pub fn balance(&self, currency: &str) -> i64 {
17
+ self.balances.get(currency).copied().unwrap_or(0)
18
+ }
19
+
20
+ /// Adds `amount`; answers the balance. `bad_request` for a negative amount, `limit` past
21
+ /// the largest balance.
22
+ pub fn credit(&mut self, currency: &str, amount: i64) -> Result<i64> {
23
+ if amount < 0 {
24
+ return Err(error(codes::BAD_REQUEST, "an amount is not negative"));
25
+ }
26
+ let now = self.balance(currency).checked_add(amount).ok_or_else(|| error(codes::LIMIT, format!("{currency} would overflow")))?;
27
+ if now > 0 {
28
+ self.balances.insert(currency.to_owned(), now);
29
+ }
30
+ Ok(now)
31
+ }
32
+
33
+ /// Takes `amount`; answers the balance. `insufficient` when there is not that much.
34
+ pub fn debit(&mut self, currency: &str, amount: i64) -> Result<i64> {
35
+ if amount < 0 {
36
+ return Err(error(codes::BAD_REQUEST, "an amount is not negative"));
37
+ }
38
+ let have = self.balance(currency);
39
+ if have < amount {
40
+ return Err(error(codes::INSUFFICIENT, format!("not {amount} {currency}")));
41
+ }
42
+ let now = have - amount;
43
+ if now == 0 {
44
+ self.balances.remove(currency);
45
+ } else {
46
+ self.balances.insert(currency.to_owned(), now);
47
+ }
48
+ Ok(now)
49
+ }
50
+ }
51
+
52
+ #[cfg(test)]
53
+ mod tests {
54
+ use super::*;
55
+
56
+ #[test]
57
+ fn a_balance_never_goes_below_zero() {
58
+ let mut w = Wallet::default();
59
+ assert_eq!(w.credit("gold", 30).unwrap(), 30);
60
+ assert!(w.debit("gold", 31).is_err());
61
+ assert_eq!(w.debit("gold", 30).unwrap(), 0);
62
+ assert!(w.balances.is_empty(), "an empty balance is not kept");
63
+ assert!(w.credit("gold", -1).is_err());
64
+ w.credit("gold", i64::MAX).unwrap();
65
+ assert!(w.credit("gold", 1).is_err(), "no overflow");
66
+ }
67
+ }
@@ -28,7 +28,9 @@
28
28
  //! answer instead of running twice.
29
29
  //!
30
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.
31
+ //! which clients branch on without reading prose. [`codes`] has the common ones. One of them,
32
+ //! [`codes::UNAVAILABLE`], says the call needed another hub that did not answer: receipts never
33
+ //! keep it, so the call runs again when it is sent again with its `action_id`.
32
34
  //!
33
35
  //! ```ignore
34
36
  //! use ckx_sdk::kit::{self, MessageKind, Publisher, Receipts};
@@ -60,8 +62,13 @@ use serde::{Deserialize, Serialize};
60
62
  use crate::model::{Model, Published, Schema, Viewer};
61
63
  use crate::{Call, Caller, Ctx, Error, Result, decode, encode};
62
64
 
65
+ pub mod ai;
66
+ pub mod econ;
67
+ pub mod play;
68
+ pub mod presence;
63
69
  pub mod rng;
64
70
  pub mod shard;
71
+ pub mod sim;
65
72
  pub mod wire;
66
73
 
67
74
  pub use rng::Rng;
@@ -131,8 +138,19 @@ struct Outgoing<'a, T: ?Sized> {
131
138
  body: &'a T,
132
139
  }
133
140
 
134
- /// Encodes one kit message from this instance. `body` must serialize as a struct or a map.
141
+ /// The fields a kit message's header owns; a body may not use them (they sit side by side).
142
+ pub const RESERVED_FIELDS: [&str; 4] = ["v", "epoch", "seq", "kind"];
143
+
144
+ /// Encodes one kit message from this instance. `body` must serialize as a struct or a map, and
145
+ /// none of its fields may be one of [`RESERVED_FIELDS`]: a debug build (`cargo test`) refuses a
146
+ /// body that uses one, which would leave a client two `kind`s to choose from.
135
147
  pub fn message<T: Serialize + ?Sized>(ctx: &Ctx, kind: MessageKind, seq: u64, body: &T) -> Result<Vec<u8>> {
148
+ #[cfg(debug_assertions)]
149
+ if let Ok(fields) = decode::<BTreeMap<String, serde::de::IgnoredAny>>(&encode(body)?)
150
+ && let Some(taken) = RESERVED_FIELDS.iter().find(|f| fields.contains_key(**f))
151
+ {
152
+ return Err(Error::new(format!("a kit message's body may not have a field `{taken}`: the header owns it")));
153
+ }
136
154
  let header = Header { v: PROTOCOL, epoch: ctx.epoch, seq, kind };
137
155
  encode(&Outgoing { header, body })
138
156
  }
@@ -237,6 +255,15 @@ pub fn args<T: DeserializeOwned>(call: &Call<'_>) -> Result<T> {
237
255
  decode(call.payload).map_err(|Error(e)| error(codes::BAD_REQUEST, format!("`{}`: {e}", call.method)))
238
256
  }
239
257
 
258
+ /// A call's arguments when it may send none: an empty payload or a nil (what a client sends for
259
+ /// no arguments) is `T::default()`.
260
+ pub fn args_or_default<T: DeserializeOwned + Default>(call: &Call<'_>) -> Result<T> {
261
+ match call.payload {
262
+ [] | [0xc0] => Ok(T::default()),
263
+ _ => args(call),
264
+ }
265
+ }
266
+
240
267
  // ---- refusals ----
241
268
 
242
269
  /// The refusal codes kit hubs share. A hub may add its own: lowercase letters, digits and `_`.
@@ -266,6 +293,9 @@ pub mod codes {
266
293
  pub const INSUFFICIENT: &str = "insufficient";
267
294
  /// The action already ran; its reply was too large to keep ([`super::RECEIPT_REPLY_MAX`]).
268
295
  pub const ALREADY_DONE: &str = "already_done";
296
+ /// Another hub this call needs did not answer in time, so the call did not finish: send it
297
+ /// again with the same `action_id`. Receipts never keep it.
298
+ pub const UNAVAILABLE: &str = "unavailable";
269
299
  }
270
300
 
271
301
  /// A refusal with a code: its message is `kit:<code>: <text>`.
@@ -358,9 +388,15 @@ impl Receipts {
358
388
  })
359
389
  }
360
390
 
361
- /// Keeps what `caller` got for `action_id`; nothing without an action id.
391
+ /// Keeps what `caller` got for `action_id`; nothing without an action id, and nothing for a
392
+ /// refusal coded [`codes::UNAVAILABLE`], which says the action did not finish.
362
393
  pub fn record(&mut self, caller: Caller, action_id: Option<&str>, result: &Result<Vec<u8>>, now: u64) {
363
394
  let (Some(who), Some(id)) = (caller_key(caller), action_id) else { return };
395
+ if let Err(Error(message)) = result
396
+ && error_code(message).is_some_and(|(c, _)| c == codes::UNAVAILABLE)
397
+ {
398
+ return;
399
+ }
364
400
  while self.kept.front().is_some_and(|r| now.saturating_sub(r.at) >= RECEIPT_TTL_MS) || self.kept.len() >= RECEIPTS_MAX {
365
401
  self.kept.pop_front();
366
402
  }
@@ -417,6 +453,22 @@ mod tests {
417
453
  assert!(message(&ctx(), MessageKind::Event, 1, &vec![1u8]).is_err(), "a body is a struct or a map");
418
454
  }
419
455
 
456
+ #[test]
457
+ fn a_refusal_coded_unavailable_is_not_a_receipt() {
458
+ let mut r = Receipts::new();
459
+ r.record(Caller::Player(1), Some("a1"), &Err(error(codes::UNAVAILABLE, "the player's hub did not answer")), 0);
460
+ assert!(r.replay(Caller::Player(1), Some("a1"), 1).is_none(), "sent again, it runs again");
461
+ r.record(Caller::Player(1), Some("a1"), &Err(error(codes::INSUFFICIENT, "not enough gold")), 2);
462
+ assert!(r.replay(Caller::Player(1), Some("a1"), 3).is_some(), "a refusal that ran is kept");
463
+ }
464
+
465
+ #[test]
466
+ fn a_body_may_not_use_a_field_the_header_owns() {
467
+ let e = message(&ctx(), MessageKind::Event, 1, &serde_json::json!({ "type": "timing", "kind": "lap" })).unwrap_err();
468
+ assert!(e.0.contains("`kind`"), "{}", e.0);
469
+ assert!(message(&ctx(), MessageKind::Event, 1, &serde_json::json!({ "type": "timing", "stage": "lap" })).is_ok());
470
+ }
471
+
420
472
  #[test]
421
473
  fn each_topic_counts_its_own_messages_and_a_snapshot_reply_names_the_last() {
422
474
  let mut p = Publisher::new();
@@ -480,6 +532,23 @@ mod tests {
480
532
  assert!(Receipts::action_id(&encode(&Act { points: 1, action_id: &long }).unwrap()).is_err());
481
533
  }
482
534
 
535
+ #[test]
536
+ fn no_arguments_are_the_defaults_and_bad_ones_are_a_bad_request() {
537
+ #[derive(Deserialize, Default, Debug, PartialEq)]
538
+ #[serde(default)]
539
+ struct Open {
540
+ seed: Option<u64>,
541
+ }
542
+ let call = |payload: &'static [u8]| Call { caller: Caller::Player(1), method: "open", payload };
543
+ assert_eq!(args_or_default::<Open>(&call(&[])).unwrap(), Open::default());
544
+ assert_eq!(args_or_default::<Open>(&call(&[0xc0])).unwrap(), Open::default());
545
+ let given = encode(&serde_json::json!({ "seed": 4 })).unwrap();
546
+ let c = Call { caller: Caller::Player(1), method: "open", payload: &given };
547
+ assert_eq!(args_or_default::<Open>(&c).unwrap(), Open { seed: Some(4) });
548
+ let Error(e) = args_or_default::<Open>(&call(&[0x05])).unwrap_err();
549
+ assert_eq!(error_code(&e).map(|c| c.0), Some(codes::BAD_REQUEST));
550
+ }
551
+
483
552
  #[test]
484
553
  fn a_collection_snapshot_holds_every_public_view() {
485
554
  let schema = Schema::new()