@crowdedkingdoms/ckx-kit 0.4.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 (37) hide show
  1. package/README.md +6 -2
  2. package/crates/ckx-sdk/Cargo.toml +1 -1
  3. package/crates/ckx-sdk/src/kit/econ/holdings.rs +241 -0
  4. package/crates/ckx-sdk/src/kit/econ/holds.rs +202 -0
  5. package/crates/ckx-sdk/src/kit/econ/items.rs +212 -0
  6. package/crates/ckx-sdk/src/kit/econ/loot.rs +116 -0
  7. package/crates/ckx-sdk/src/kit/econ/mod.rs +50 -0
  8. package/crates/ckx-sdk/src/kit/econ/orderbook.rs +155 -0
  9. package/crates/ckx-sdk/src/kit/econ/production.rs +155 -0
  10. package/crates/ckx-sdk/src/kit/econ/progression.rs +254 -0
  11. package/crates/ckx-sdk/src/kit/econ/recipes.rs +65 -0
  12. package/crates/ckx-sdk/src/kit/econ/vendor.rs +116 -0
  13. package/crates/ckx-sdk/src/kit/econ/wallet.rs +67 -0
  14. package/crates/ckx-sdk/src/kit/mod.rs +23 -2
  15. package/crates/ckx-sdk/src/lib.rs +2 -1
  16. package/dist/cli/src/new.js +2 -0
  17. package/kit.pin +4 -4
  18. package/package.json +1 -1
  19. package/starters/arena/Cargo.toml +1 -1
  20. package/starters/ckx.json +3 -1
  21. package/starters/deck/Cargo.toml +1 -1
  22. package/starters/director/Cargo.toml +1 -1
  23. package/starters/grid-mod/Cargo.toml +1 -1
  24. package/starters/instance/Cargo.toml +1 -1
  25. package/starters/leaderboard/Cargo.toml +1 -1
  26. package/starters/market/Cargo.toml +14 -0
  27. package/starters/market/src/lib.rs +1137 -0
  28. package/starters/matchmaker/Cargo.toml +1 -1
  29. package/starters/minigame/Cargo.toml +1 -1
  30. package/starters/npc-mobs/Cargo.toml +1 -1
  31. package/starters/player/Cargo.toml +14 -0
  32. package/starters/player/src/lib.rs +994 -0
  33. package/starters/racing/Cargo.toml +1 -1
  34. package/starters/session/Cargo.toml +1 -1
  35. package/starters/tactics/Cargo.toml +1 -1
  36. package/starters/territory/Cargo.toml +1 -1
  37. 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};
@@ -61,6 +63,7 @@ use crate::model::{Model, Published, Schema, Viewer};
61
63
  use crate::{Call, Caller, Ctx, Error, Result, decode, encode};
62
64
 
63
65
  pub mod ai;
66
+ pub mod econ;
64
67
  pub mod play;
65
68
  pub mod presence;
66
69
  pub mod rng;
@@ -290,6 +293,9 @@ pub mod codes {
290
293
  pub const INSUFFICIENT: &str = "insufficient";
291
294
  /// The action already ran; its reply was too large to keep ([`super::RECEIPT_REPLY_MAX`]).
292
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";
293
299
  }
294
300
 
295
301
  /// A refusal with a code: its message is `kit:<code>: <text>`.
@@ -382,9 +388,15 @@ impl Receipts {
382
388
  })
383
389
  }
384
390
 
385
- /// 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.
386
393
  pub fn record(&mut self, caller: Caller, action_id: Option<&str>, result: &Result<Vec<u8>>, now: u64) {
387
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
+ }
388
400
  while self.kept.front().is_some_and(|r| now.saturating_sub(r.at) >= RECEIPT_TTL_MS) || self.kept.len() >= RECEIPTS_MAX {
389
401
  self.kept.pop_front();
390
402
  }
@@ -441,6 +453,15 @@ mod tests {
441
453
  assert!(message(&ctx(), MessageKind::Event, 1, &vec![1u8]).is_err(), "a body is a struct or a map");
442
454
  }
443
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
+
444
465
  #[test]
445
466
  fn a_body_may_not_use_a_field_the_header_owns() {
446
467
  let e = message(&ctx(), MessageKind::Event, 1, &serde_json::json!({ "type": "timing", "kind": "lap" })).unwrap_err();
@@ -47,7 +47,8 @@
47
47
  //!
48
48
  //! [`kit`] is the game kit's half here: the protocol kit hubs speak to the kit clients
49
49
  //! (snapshots and deltas with sequence numbers, retry-safe actions, refusal codes), shard keys,
50
- //! a seeded random generator, and the realtime event registry.
50
+ //! a seeded random generator, the realtime event registry, and the pieces games are made of
51
+ //! (`kit::play`, `kit::sim`, `kit::ai`, and the soft economy in `kit::econ`).
51
52
 
52
53
  mod abi;
53
54
  pub mod cron;
@@ -24,6 +24,8 @@ exports.TEMPLATES = {
24
24
  arena: { dir: ['starters', 'arena'], sdk: 'ckx-sdk', about: 'classes and abilities in real time' },
25
25
  racing: { dir: ['starters', 'racing'], sdk: 'ckx-sdk', about: 'races with laps and a ghost, and ball games' },
26
26
  territory: { dir: ['starters', 'territory'], sdk: 'ckx-sdk', about: 'control points, and a shrinking circle' },
27
+ player: { dir: ['starters', 'player'], sdk: 'ckx-sdk', about: "a player's own hub: items, currency, crafting, quests (not a ledger)" },
28
+ market: { dir: ['starters', 'market'], sdk: 'ckx-sdk', about: 'vendors, an order book, trades between players (not a ledger)' },
27
29
  };
28
30
  const CRATE_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
29
31
  function copyTree(from, to) {
package/kit.pin CHANGED
@@ -1,9 +1,9 @@
1
1
  # The platform toolchain release this kit was copied from (sync-kit.sh in the platform's
2
2
  # repository). The paths in `copied` are byte-identical to it: change them there, never here.
3
- toolchain_version=v1.13.0
4
- toolchain_commit=4e6f1a7c9502d5d7171d75b6063acec97172239e
5
- sdk_commit=04d42bc77e24b6d84debc71eeee0efc687c0ee9a
6
- ckx_sdk_version=0.11.0
3
+ toolchain_version=v1.15.0
4
+ toolchain_commit=8447d24998210f5d00f94ec2ad8781bc0b4b6e0a
5
+ sdk_commit=c682f9378d742b7420a3bf0b49309cc27fba85bb
6
+ ckx_sdk_version=0.12.1
7
7
  crowdy_client_sdk_version=0.1.0
8
8
  rust_version=1.97.1
9
9
  copied=crates/ckx-sdk/src crates/crowdy-client-sdk/src tools/instrument/Cargo.toml tools/instrument/Cargo.lock tools/instrument/src checks/src/build/exec-source.ts checks/src/build/exec-source.spec.ts checks/src/build/rust-source-guard.ts checks/src/build/rust-source-guard.spec.ts checks/src/build/wasm-inspect.ts checks/src/build/wasm-inspect.spec.ts checks/src/build/client-capabilities.ts checks/src/build/client-capabilities.spec.ts checks/src/test-support/wasm-module.ts checks/compute-toolchain/host-catalog.json starters rust-toolchain.toml vendor.lock
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowdedkingdoms/ckx-kit",
3
- "version": "0.4.0-dev.1",
3
+ "version": "0.5.0-dev.1",
4
4
  "description": "Build, check and test Crowded Kingdoms ck-exec mods, hubs and spokes on your machine, as the platform builds them",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "homepage": "https://docs.crowdedkingdoms.com/exec/intro",
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.11.0"
12
+ ckx-sdk = "0.12.1"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
package/starters/ckx.json CHANGED
@@ -15,6 +15,8 @@
15
15
  "race": { "kind": "hub", "parent": "world", "crate": "racing", "wasm": "racing.wasm", "client": true, "calls": ["leaderboard"] },
16
16
  "ball": { "kind": "hub", "parent": "world", "crate": "racing", "wasm": "racing.wasm", "client": true },
17
17
  "territory": { "kind": "hub", "parent": "world", "crate": "territory", "wasm": "territory.wasm", "client": true, "scopes": ["world.read"] },
18
- "zone": { "kind": "hub", "parent": "world", "crate": "territory", "wasm": "territory.wasm", "client": true, "scopes": ["world.read"] }
18
+ "zone": { "kind": "hub", "parent": "world", "crate": "territory", "wasm": "territory.wasm", "client": true, "scopes": ["world.read"] },
19
+ "player": { "kind": "hub", "parent": "world", "crate": "player", "wasm": "player.wasm", "client": true },
20
+ "market": { "kind": "hub", "parent": "world", "crate": "market", "wasm": "market.wasm", "client": true, "calls": ["player"] }
19
21
  }
20
22
  }
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.11.0"
12
+ ckx-sdk = "0.12.1"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.11.0"
12
+ ckx-sdk = "0.12.1"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,5 +9,5 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.11.0"
12
+ ckx-sdk = "0.12.1"
13
13
  serde = { version = "1", features = ["derive"] }
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.11.0"
12
+ ckx-sdk = "0.12.1"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"
@@ -9,6 +9,6 @@ publish = false
9
9
  crate-type = ["cdylib"]
10
10
 
11
11
  [dependencies]
12
- ckx-sdk = "0.11.0"
12
+ ckx-sdk = "0.12.1"
13
13
  serde = { version = "1", features = ["derive"] }
14
14
  serde_json = "1"