@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,241 @@
1
+ //! What a player owns: their inventory, their wallet, and the mail that holds items that did not
2
+ //! fit ([`Holdings`]); and items and currency moved together ([`Bundle`]).
3
+
4
+ use std::collections::BTreeMap;
5
+
6
+ use serde::{Deserialize, Serialize};
7
+
8
+ use super::check_id;
9
+ use super::items::{Catalog, Inventory};
10
+ use super::wallet::Wallet;
11
+ use crate::Result;
12
+ use crate::kit::{codes, error};
13
+
14
+ /// Items and currency moved together: a price, a reward, a hold.
15
+ #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
16
+ #[serde(default)]
17
+ pub struct Bundle {
18
+ /// Item id to how many.
19
+ pub items: BTreeMap<String, u64>,
20
+ /// Currency to how much.
21
+ pub currency: BTreeMap<String, i64>,
22
+ }
23
+
24
+ impl Bundle {
25
+ pub fn item(id: &str, count: u64) -> Self {
26
+ Self { items: BTreeMap::from([(id.to_owned(), count)]), currency: BTreeMap::new() }
27
+ }
28
+
29
+ pub fn coins(currency: &str, amount: i64) -> Self {
30
+ Self { items: BTreeMap::new(), currency: BTreeMap::from([(currency.to_owned(), amount)]) }
31
+ }
32
+
33
+ pub fn is_empty(&self) -> bool {
34
+ self.items.values().all(|&n| n == 0) && self.currency.values().all(|&n| n == 0)
35
+ }
36
+
37
+ /// `bad_request` for an id the economy does not take or a negative amount.
38
+ pub fn check(&self) -> Result<()> {
39
+ for id in self.items.keys() {
40
+ check_id("item id", id)?;
41
+ }
42
+ for (id, &amount) in &self.currency {
43
+ check_id("currency", id)?;
44
+ if amount < 0 {
45
+ return Err(error(codes::BAD_REQUEST, "an amount is not negative"));
46
+ }
47
+ }
48
+ Ok(())
49
+ }
50
+
51
+ pub fn plus(&self, other: &Bundle) -> Result<Bundle> {
52
+ let mut out = self.clone();
53
+ for (id, &n) in &other.items {
54
+ let e = out.items.entry(id.clone()).or_insert(0);
55
+ *e = e.checked_add(n).ok_or_else(|| error(codes::LIMIT, "too many"))?;
56
+ }
57
+ for (id, &n) in &other.currency {
58
+ let e = out.currency.entry(id.clone()).or_insert(0);
59
+ *e = e.checked_add(n).ok_or_else(|| error(codes::LIMIT, "too much"))?;
60
+ }
61
+ Ok(out.trimmed())
62
+ }
63
+
64
+ /// What is left of `self` once `part` is taken out of it; `insufficient` when it holds less.
65
+ pub fn minus(&self, part: &Bundle) -> Result<Bundle> {
66
+ let mut out = self.clone();
67
+ for (id, &n) in &part.items {
68
+ let e = out.items.entry(id.clone()).or_insert(0);
69
+ *e = e.checked_sub(n).ok_or_else(|| error(codes::INSUFFICIENT, format!("not {n} {id}")))?;
70
+ }
71
+ for (id, &n) in &part.currency {
72
+ let e = out.currency.entry(id.clone()).or_insert(0);
73
+ if *e < n {
74
+ return Err(error(codes::INSUFFICIENT, format!("not {n} {id}")));
75
+ }
76
+ *e -= n;
77
+ }
78
+ Ok(out.trimmed())
79
+ }
80
+
81
+ /// `n` of it.
82
+ pub fn times(&self, n: u64) -> Result<Bundle> {
83
+ let k = i64::try_from(n).map_err(|_| error(codes::LIMIT, "too many"))?;
84
+ let mut out = Bundle::default();
85
+ for (id, &c) in &self.items {
86
+ out.items.insert(id.clone(), c.checked_mul(n).ok_or_else(|| error(codes::LIMIT, "too many"))?);
87
+ }
88
+ for (id, &c) in &self.currency {
89
+ out.currency.insert(id.clone(), c.checked_mul(k).ok_or_else(|| error(codes::LIMIT, "too much"))?);
90
+ }
91
+ Ok(out.trimmed())
92
+ }
93
+
94
+ /// Without its zero entries.
95
+ pub fn trimmed(mut self) -> Bundle {
96
+ self.items.retain(|_, n| *n > 0);
97
+ self.currency.retain(|_, n| *n != 0);
98
+ self
99
+ }
100
+ }
101
+
102
+ /// A player's inventory and wallet, and their mail: items given to them that did not fit,
103
+ /// kept until they make room ([`Holdings::take_mail`]).
104
+ #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
105
+ #[serde(default)]
106
+ pub struct Holdings {
107
+ pub inventory: Inventory,
108
+ pub wallet: Wallet,
109
+ pub mail: BTreeMap<String, u64>,
110
+ }
111
+
112
+ impl Holdings {
113
+ pub fn new(slots: u32) -> Self {
114
+ Self { inventory: Inventory::new(slots), ..Self::default() }
115
+ }
116
+
117
+ /// Whether `b` could be taken.
118
+ pub fn has(&self, b: &Bundle) -> bool {
119
+ b.items.iter().all(|(id, &n)| self.inventory.count(id) >= n) && b.currency.iter().all(|(id, &n)| self.wallet.balance(id) >= n)
120
+ }
121
+
122
+ /// Takes `b`, or nothing: `insufficient`.
123
+ pub fn take(&mut self, b: &Bundle) -> Result<()> {
124
+ b.check()?;
125
+ if !self.has(b) {
126
+ return Err(error(codes::INSUFFICIENT, "not enough"));
127
+ }
128
+ for (id, &n) in &b.items {
129
+ self.inventory.remove(id, n)?;
130
+ }
131
+ for (id, &n) in &b.currency {
132
+ self.wallet.debit(id, n)?;
133
+ }
134
+ Ok(())
135
+ }
136
+
137
+ /// Gives `b`, or nothing: `full` when its items do not fit, `not_found` for an item the
138
+ /// catalog lacks.
139
+ pub fn give(&mut self, catalog: &Catalog, b: &Bundle) -> Result<()> {
140
+ b.check()?;
141
+ let mut next = self.clone();
142
+ for (id, &n) in &b.items {
143
+ next.inventory.add(catalog, id, n)?;
144
+ }
145
+ for (id, &n) in &b.currency {
146
+ next.wallet.credit(id, n)?;
147
+ }
148
+ *self = next;
149
+ Ok(())
150
+ }
151
+
152
+ /// Gives `b`, its items as far as they fit and the rest to the mail. It fails only for a
153
+ /// bundle the economy does not take: what another hub delivers always arrives.
154
+ pub fn give_or_mail(&mut self, catalog: &Catalog, b: &Bundle) -> Result<()> {
155
+ b.check()?;
156
+ for id in b.items.keys() {
157
+ catalog.item(id)?;
158
+ }
159
+ let mut next = self.clone();
160
+ for (id, &n) in &b.currency {
161
+ next.wallet.credit(id, n)?;
162
+ }
163
+ for (id, &n) in &b.items {
164
+ let left = next.inventory.add_what_fits(catalog, id, n);
165
+ if left > 0 {
166
+ *next.mail.entry(id.clone()).or_insert(0) += left;
167
+ }
168
+ }
169
+ *self = next;
170
+ Ok(())
171
+ }
172
+
173
+ /// Takes `pay` and gives `get` together, or neither.
174
+ pub fn swap(&mut self, catalog: &Catalog, pay: &Bundle, get: &Bundle) -> Result<()> {
175
+ let mut next = self.clone();
176
+ next.take(pay)?;
177
+ next.give(catalog, get)?;
178
+ *self = next;
179
+ Ok(())
180
+ }
181
+
182
+ /// Moves what fits from the mail into the inventory; answers what moved.
183
+ pub fn take_mail(&mut self, catalog: &Catalog) -> Bundle {
184
+ let mut moved = Bundle::default();
185
+ let mail = std::mem::take(&mut self.mail);
186
+ for (id, n) in mail {
187
+ let left = self.inventory.add_what_fits(catalog, &id, n);
188
+ if n > left {
189
+ moved.items.insert(id.clone(), n - left);
190
+ }
191
+ if left > 0 {
192
+ self.mail.insert(id, left);
193
+ }
194
+ }
195
+ moved
196
+ }
197
+ }
198
+
199
+ #[cfg(test)]
200
+ mod tests {
201
+ use super::*;
202
+ use crate::kit::econ::items::ItemDef;
203
+
204
+ fn catalog() -> Catalog {
205
+ Catalog::from(vec![ItemDef { id: "ore".into(), max_stack: 10, tradable: true, name: String::new() }])
206
+ }
207
+
208
+ #[test]
209
+ fn a_swap_is_all_or_nothing() {
210
+ let c = catalog();
211
+ let mut h = Holdings::new(1);
212
+ h.give(&c, &Bundle::coins("gold", 50)).unwrap();
213
+ let before = h.clone();
214
+ assert!(h.swap(&c, &Bundle::coins("gold", 20), &Bundle::item("ore", 11)).is_err(), "11 ore do not fit one slot");
215
+ assert_eq!(h, before);
216
+ h.swap(&c, &Bundle::coins("gold", 20), &Bundle::item("ore", 10)).unwrap();
217
+ assert_eq!((h.wallet.balance("gold"), h.inventory.count("ore")), (30, 10));
218
+ }
219
+
220
+ #[test]
221
+ fn what_does_not_fit_waits_in_the_mail() {
222
+ let c = catalog();
223
+ let mut h = Holdings::new(1);
224
+ h.give_or_mail(&c, &Bundle::item("ore", 14).plus(&Bundle::coins("gold", 3)).unwrap()).unwrap();
225
+ assert_eq!((h.inventory.count("ore"), h.mail["ore"], h.wallet.balance("gold")), (10, 4, 3));
226
+ h.take(&Bundle::item("ore", 6)).unwrap();
227
+ assert_eq!(h.take_mail(&c), Bundle::item("ore", 4));
228
+ assert!(h.mail.is_empty());
229
+ }
230
+
231
+ #[test]
232
+ fn bundles_add_take_and_multiply_without_overflow() {
233
+ let price = Bundle::coins("gold", 7);
234
+ assert_eq!(price.times(3).unwrap(), Bundle::coins("gold", 21));
235
+ assert!(price.times(u64::MAX).is_err());
236
+ let both = price.plus(&Bundle::item("ore", 2)).unwrap();
237
+ assert_eq!(both.minus(&Bundle::item("ore", 2)).unwrap(), price);
238
+ assert!(both.minus(&Bundle::coins("gold", 8)).is_err());
239
+ assert!(Bundle::coins("gold", -1).check().is_err());
240
+ }
241
+ }
@@ -0,0 +1,202 @@
1
+ //! Escrow in the owner's own hub, and the references a hub answers once.
2
+ //!
3
+ //! A transfer between two players' hubs never leaves goods in neither or both: whatever drives
4
+ //! it (a market) first [`Holds::hold`]s them in the giver's hub under a reference, later
5
+ //! [`Holds::settle`]s the hold (the goods leave for good) and delivers them to the taker, or
6
+ //! [`Holds::release`]s it back. Every step another hub asks for carries a reference of its own,
7
+ //! which [`Applied`] answers once, so the driver retries a step until it gets an answer.
8
+ //!
9
+ //! A reference names one change, for as long as [`Applied`] keeps its answer: a driver never
10
+ //! sends it for another, or the other is answered as the first was without running. So a driver
11
+ //! makes references from its own key and counters, never from what a client sent (an `action_id`
12
+ //! is the client's to reuse), and remembers which reference a client's resend should ask again.
13
+
14
+ use std::collections::{BTreeMap, VecDeque};
15
+
16
+ use serde::{Deserialize, Serialize};
17
+
18
+ use super::check_reference;
19
+ use super::holdings::{Bundle, Holdings};
20
+ use super::items::Catalog;
21
+ use crate::kit::{codes, error, error_code};
22
+ use crate::{Error, Result};
23
+
24
+ /// Goods held under references, out of their owner's holdings until settled or released.
25
+ #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
26
+ #[serde(default)]
27
+ pub struct Holds {
28
+ pub held: BTreeMap<String, Bundle>,
29
+ }
30
+
31
+ impl Holds {
32
+ /// Moves `bundle` out of `holdings` into a hold under `reference`. The same reference with
33
+ /// the same bundle again is the hold already made; with another, `conflict`.
34
+ pub fn hold(&mut self, holdings: &mut Holdings, reference: &str, bundle: &Bundle) -> Result<()> {
35
+ check_reference(reference)?;
36
+ bundle.check()?;
37
+ if let Some(held) = self.held.get(reference) {
38
+ return if held == bundle { Ok(()) } else { Err(error(codes::CONFLICT, format!("`{reference}` holds something else"))) };
39
+ }
40
+ holdings.take(bundle)?;
41
+ self.held.insert(reference.to_owned(), bundle.clone().trimmed());
42
+ Ok(())
43
+ }
44
+
45
+ /// Takes `part` out of the hold for good; the rest stays held, and an emptied hold is gone.
46
+ /// `not_found` for a reference not held, `insufficient` when it holds less.
47
+ pub fn settle(&mut self, reference: &str, part: &Bundle) -> Result<()> {
48
+ let held = self.held.get(reference).ok_or_else(|| error(codes::NOT_FOUND, format!("nothing is held as `{reference}`")))?;
49
+ let rest = held.minus(part)?;
50
+ if rest.is_empty() {
51
+ self.held.remove(reference);
52
+ } else {
53
+ self.held.insert(reference.to_owned(), rest);
54
+ }
55
+ Ok(())
56
+ }
57
+
58
+ /// Gives what the hold still has back to its owner (items that do not fit go to the mail);
59
+ /// answers what came back, nothing for a reference not held.
60
+ pub fn release(&mut self, holdings: &mut Holdings, catalog: &Catalog, reference: &str) -> Result<Bundle> {
61
+ let Some(held) = self.held.remove(reference) else { return Ok(Bundle::default()) };
62
+ holdings.give_or_mail(catalog, &held)?;
63
+ Ok(held)
64
+ }
65
+
66
+ /// Everything held, together.
67
+ pub fn total(&self) -> Bundle {
68
+ self.held.values().fold(Bundle::default(), |acc, b| acc.plus(b).unwrap_or(acc))
69
+ }
70
+ }
71
+
72
+ /// How long a reference's answer is kept.
73
+ pub const APPLIED_TTL_MS: u64 = 7 * 24 * 3_600_000;
74
+ /// How many references' answers are kept.
75
+ pub const APPLIED_MAX: usize = 4_096;
76
+
77
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
78
+ struct Done {
79
+ reference: String,
80
+ at: u64,
81
+ ok: bool,
82
+ reply: Vec<u8>,
83
+ }
84
+
85
+ /// The answer each reference got, so a step asked for again is answered as it was the first
86
+ /// time. Keep it in the hub's state. A refusal coded `unavailable` is not kept: it says the step
87
+ /// did not run.
88
+ #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
89
+ pub struct Applied {
90
+ #[serde(default)]
91
+ kept: VecDeque<Done>,
92
+ }
93
+
94
+ impl Applied {
95
+ pub fn replay(&self, reference: &str, now: u64) -> Option<Result<Vec<u8>>> {
96
+ let d = self.kept.iter().rev().find(|d| d.reference == reference && now.saturating_sub(d.at) < APPLIED_TTL_MS)?;
97
+ Some(if d.ok { Ok(d.reply.clone()) } else { Err(Error::new(String::from_utf8_lossy(&d.reply).into_owned())) })
98
+ }
99
+
100
+ pub fn record(&mut self, reference: &str, result: &Result<Vec<u8>>, now: u64) {
101
+ if let Err(e) = result
102
+ && error_code(&e.0).is_some_and(|(c, _)| c == codes::UNAVAILABLE)
103
+ {
104
+ return;
105
+ }
106
+ while self.kept.front().is_some_and(|d| now.saturating_sub(d.at) >= APPLIED_TTL_MS) || self.kept.len() >= APPLIED_MAX {
107
+ self.kept.pop_front();
108
+ }
109
+ let (ok, reply) = match result {
110
+ Ok(r) => (true, r.clone()),
111
+ Err(e) => (false, e.0.clone().into_bytes()),
112
+ };
113
+ self.kept.push_back(Done { reference: reference.to_owned(), at: now, ok, reply });
114
+ }
115
+
116
+ /// Runs `act` once per `reference`: asked again, it answers what it answered first.
117
+ pub fn run<H>(
118
+ hub: &mut H,
119
+ applied: fn(&mut H) -> &mut Applied,
120
+ reference: &str,
121
+ now: u64,
122
+ act: impl FnOnce(&mut H) -> Result<Vec<u8>>,
123
+ ) -> Result<Vec<u8>> {
124
+ check_reference(reference)?;
125
+ if let Some(answer) = applied(hub).replay(reference, now) {
126
+ return answer;
127
+ }
128
+ let result = act(hub);
129
+ applied(hub).record(reference, &result, now);
130
+ result
131
+ }
132
+
133
+ pub fn len(&self) -> usize {
134
+ self.kept.len()
135
+ }
136
+
137
+ pub fn is_empty(&self) -> bool {
138
+ self.kept.is_empty()
139
+ }
140
+ }
141
+
142
+ #[cfg(test)]
143
+ mod tests {
144
+ use super::*;
145
+ use crate::kit::econ::items::ItemDef;
146
+
147
+ fn setup() -> (Catalog, Holdings) {
148
+ let c = Catalog::from(vec![ItemDef { id: "ore".into(), max_stack: 10, tradable: true, name: String::new() }]);
149
+ let mut h = Holdings::new(2);
150
+ h.give(&c, &Bundle::item("ore", 8).plus(&Bundle::coins("gold", 40)).unwrap()).unwrap();
151
+ (c, h)
152
+ }
153
+
154
+ #[test]
155
+ fn a_hold_leaves_the_holdings_and_comes_back_on_release() {
156
+ let (c, mut h) = setup();
157
+ let mut holds = Holds::default();
158
+ holds.hold(&mut h, "o:1", &Bundle::coins("gold", 30)).unwrap();
159
+ holds.hold(&mut h, "o:1", &Bundle::coins("gold", 30)).unwrap();
160
+ assert_eq!(h.wallet.balance("gold"), 10, "the same hold twice is one hold");
161
+ assert!(holds.hold(&mut h, "o:1", &Bundle::coins("gold", 5)).is_err(), "a reference holds one thing");
162
+ assert!(holds.hold(&mut h, "o:2", &Bundle::coins("gold", 11)).is_err(), "not more than there is");
163
+ holds.settle("o:1", &Bundle::coins("gold", 12)).unwrap();
164
+ assert_eq!(holds.release(&mut h, &c, "o:1").unwrap(), Bundle::coins("gold", 18));
165
+ assert_eq!(h.wallet.balance("gold"), 28);
166
+ assert_eq!(holds.release(&mut h, &c, "o:1").unwrap(), Bundle::default(), "released once");
167
+ assert!(holds.settle("o:1", &Bundle::coins("gold", 1)).is_err());
168
+ }
169
+
170
+ #[test]
171
+ fn a_reference_is_longer_than_an_id_but_bounded() {
172
+ let (_, mut h) = setup();
173
+ let mut holds = Holds::default();
174
+ let long = "r".repeat(crate::kit::econ::REFERENCE_MAX);
175
+ holds.hold(&mut h, &long, &Bundle::coins("gold", 1)).unwrap();
176
+ assert!(holds.hold(&mut h, &format!("{long}r"), &Bundle::coins("gold", 1)).is_err());
177
+ let mut applied = Applied::default();
178
+ assert!(Applied::run(&mut applied, |a| a, &format!("{long}r"), 0, |_| Ok(vec![])).is_err());
179
+ }
180
+
181
+ #[test]
182
+ fn a_reference_is_answered_once_and_unavailable_is_not_an_answer() {
183
+ struct Hub {
184
+ applied: Applied,
185
+ runs: u32,
186
+ }
187
+ let mut hub = Hub { applied: Applied::default(), runs: 0 };
188
+ let act = |h: &mut Hub| {
189
+ h.runs += 1;
190
+ Ok(vec![1])
191
+ };
192
+ assert_eq!(Applied::run(&mut hub, |h| &mut h.applied, "d:1", 0, act).unwrap(), vec![1]);
193
+ assert_eq!(Applied::run(&mut hub, |h| &mut h.applied, "d:1", 5, act).unwrap(), vec![1]);
194
+ assert_eq!(hub.runs, 1);
195
+ let mut applied = Applied::default();
196
+ applied.record("d:2", &Err(error(codes::UNAVAILABLE, "the market did not answer")), 0);
197
+ assert!(applied.replay("d:2", 1).is_none());
198
+ applied.record("d:3", &Err(error(codes::INSUFFICIENT, "no")), 0);
199
+ assert!(applied.replay("d:3", 1).unwrap().is_err());
200
+ assert!(applied.replay("d:3", APPLIED_TTL_MS).is_none(), "kept for a week");
201
+ }
202
+ }
@@ -0,0 +1,212 @@
1
+ //! Items: what a game's items are ([`Catalog`]), and an inventory of stacks with a number of
2
+ //! slots ([`Inventory`]).
3
+
4
+ use std::collections::BTreeMap;
5
+
6
+ use serde::{Deserialize, Serialize};
7
+
8
+ use crate::Result;
9
+ use crate::kit::{codes, error};
10
+
11
+ fn one() -> u64 {
12
+ 1
13
+ }
14
+
15
+ fn yes() -> bool {
16
+ true
17
+ }
18
+
19
+ /// One kind of item.
20
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
21
+ pub struct ItemDef {
22
+ pub id: String,
23
+ /// What one slot holds; 1 for an item that does not stack.
24
+ #[serde(default = "one")]
25
+ pub max_stack: u64,
26
+ /// Whether it may change hands (a market's order book, a trade).
27
+ #[serde(default = "yes")]
28
+ pub tradable: bool,
29
+ #[serde(default)]
30
+ pub name: String,
31
+ }
32
+
33
+ /// The game's items, by id.
34
+ #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
35
+ #[serde(from = "Vec<ItemDef>", into = "Vec<ItemDef>")]
36
+ pub struct Catalog {
37
+ items: BTreeMap<String, ItemDef>,
38
+ }
39
+
40
+ impl From<Vec<ItemDef>> for Catalog {
41
+ fn from(items: Vec<ItemDef>) -> Self {
42
+ Self { items: items.into_iter().map(|i| (i.id.clone(), i)).collect() }
43
+ }
44
+ }
45
+
46
+ impl From<Catalog> for Vec<ItemDef> {
47
+ fn from(c: Catalog) -> Self {
48
+ c.items.into_values().collect()
49
+ }
50
+ }
51
+
52
+ impl Catalog {
53
+ pub fn get(&self, id: &str) -> Option<&ItemDef> {
54
+ self.items.get(id)
55
+ }
56
+
57
+ /// The item, or `not_found`.
58
+ pub fn item(&self, id: &str) -> Result<&ItemDef> {
59
+ self.items.get(id).ok_or_else(|| error(codes::NOT_FOUND, format!("there is no item `{id}`")))
60
+ }
61
+
62
+ pub fn len(&self) -> usize {
63
+ self.items.len()
64
+ }
65
+
66
+ pub fn is_empty(&self) -> bool {
67
+ self.items.is_empty()
68
+ }
69
+
70
+ pub fn iter(&self) -> impl Iterator<Item = &ItemDef> {
71
+ self.items.values()
72
+ }
73
+ }
74
+
75
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
76
+ pub struct Stack {
77
+ pub item: String,
78
+ pub count: u64,
79
+ }
80
+
81
+ /// Stacks of items in a number of slots. Adding fills the item's stacks before starting new
82
+ /// ones; a stack holds at most its item's `max_stack`.
83
+ #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
84
+ pub struct Inventory {
85
+ pub slots: u32,
86
+ pub stacks: Vec<Stack>,
87
+ }
88
+
89
+ impl Inventory {
90
+ pub fn new(slots: u32) -> Self {
91
+ Self { slots, stacks: Vec::new() }
92
+ }
93
+
94
+ pub fn count(&self, item: &str) -> u64 {
95
+ self.stacks.iter().filter(|s| s.item == item).map(|s| s.count).sum()
96
+ }
97
+
98
+ /// How many of `item` still fit.
99
+ pub fn room(&self, catalog: &Catalog, item: &str) -> u64 {
100
+ let Some(def) = catalog.get(item) else { return 0 };
101
+ let max = def.max_stack.max(1);
102
+ let in_stacks: u64 = self.stacks.iter().filter(|s| s.item == item).map(|s| max.saturating_sub(s.count)).sum();
103
+ let free = u64::from(self.slots).saturating_sub(self.stacks.len() as u64);
104
+ in_stacks.saturating_add(free.saturating_mul(max))
105
+ }
106
+
107
+ /// Adds `count` of `item`, or nothing: `not_found` for an item the catalog lacks, `full`
108
+ /// when they do not fit.
109
+ pub fn add(&mut self, catalog: &Catalog, item: &str, count: u64) -> Result<()> {
110
+ let max = catalog.item(item)?.max_stack.max(1);
111
+ if count > self.room(catalog, item) {
112
+ return Err(error(codes::FULL, format!("{count} {item} do not fit")));
113
+ }
114
+ let mut left = count;
115
+ for s in self.stacks.iter_mut().filter(|s| s.item == item) {
116
+ let put = left.min(max.saturating_sub(s.count));
117
+ s.count += put;
118
+ left -= put;
119
+ }
120
+ while left > 0 {
121
+ let put = left.min(max);
122
+ self.stacks.push(Stack { item: item.to_owned(), count: put });
123
+ left -= put;
124
+ }
125
+ Ok(())
126
+ }
127
+
128
+ /// Adds what fits; answers how many did not.
129
+ pub fn add_what_fits(&mut self, catalog: &Catalog, item: &str, count: u64) -> u64 {
130
+ let fits = count.min(self.room(catalog, item));
131
+ if fits > 0 && self.add(catalog, item, fits).is_err() {
132
+ return count;
133
+ }
134
+ count - fits
135
+ }
136
+
137
+ /// Removes `count` of `item`, from its last stacks first, or nothing: `insufficient`.
138
+ pub fn remove(&mut self, item: &str, count: u64) -> Result<()> {
139
+ if self.count(item) < count {
140
+ return Err(error(codes::INSUFFICIENT, format!("not {count} {item}")));
141
+ }
142
+ let mut left = count;
143
+ for s in self.stacks.iter_mut().rev().filter(|s| s.item == item) {
144
+ let take = left.min(s.count);
145
+ s.count -= take;
146
+ left -= take;
147
+ if left == 0 {
148
+ break;
149
+ }
150
+ }
151
+ self.stacks.retain(|s| s.count > 0);
152
+ Ok(())
153
+ }
154
+
155
+ /// Every item's total, by id.
156
+ pub fn totals(&self) -> BTreeMap<String, u64> {
157
+ let mut out = BTreeMap::new();
158
+ for s in &self.stacks {
159
+ *out.entry(s.item.clone()).or_insert(0) += s.count;
160
+ }
161
+ out
162
+ }
163
+ }
164
+
165
+ #[cfg(test)]
166
+ mod tests {
167
+ use super::*;
168
+
169
+ fn catalog() -> Catalog {
170
+ Catalog::from(vec![
171
+ ItemDef { id: "wood".into(), max_stack: 10, tradable: true, name: String::new() },
172
+ ItemDef { id: "sword".into(), max_stack: 1, tradable: true, name: String::new() },
173
+ ])
174
+ }
175
+
176
+ #[test]
177
+ fn stacks_fill_before_new_slots_and_a_full_inventory_takes_nothing() {
178
+ let c = catalog();
179
+ let mut inv = Inventory::new(2);
180
+ inv.add(&c, "wood", 7).unwrap();
181
+ inv.add(&c, "wood", 5).unwrap();
182
+ assert_eq!(inv.stacks.iter().map(|s| s.count).collect::<Vec<_>>(), vec![10, 2]);
183
+ assert_eq!(inv.room(&c, "wood"), 8);
184
+ assert_eq!(inv.room(&c, "sword"), 0, "both slots are taken");
185
+ let before = inv.clone();
186
+ assert!(inv.add(&c, "wood", 9).is_err());
187
+ assert_eq!(inv, before, "all or nothing");
188
+ assert_eq!(inv.add_what_fits(&c, "wood", 9), 1);
189
+ assert!(inv.add(&c, "stone", 1).is_err(), "not in the catalog");
190
+ }
191
+
192
+ #[test]
193
+ fn removing_takes_from_the_last_stacks_and_frees_their_slots() {
194
+ let c = catalog();
195
+ let mut inv = Inventory::new(3);
196
+ inv.add(&c, "wood", 14).unwrap();
197
+ inv.remove("wood", 6).unwrap();
198
+ assert_eq!(inv.stacks, vec![Stack { item: "wood".into(), count: 8 }]);
199
+ assert!(inv.remove("wood", 9).is_err());
200
+ assert_eq!(inv.count("wood"), 8);
201
+ }
202
+
203
+ #[test]
204
+ fn a_catalog_reads_and_writes_as_a_list() {
205
+ let json = r#"[{"id":"wood","max_stack":10},{"id":"sword"}]"#;
206
+ let c: Catalog = serde_json::from_str(json).unwrap();
207
+ assert_eq!(c.item("sword").unwrap().max_stack, 1);
208
+ assert!(c.item("wood").unwrap().tradable);
209
+ let back = serde_json::to_value(&c).unwrap();
210
+ assert_eq!(back.as_array().unwrap().len(), 2);
211
+ }
212
+ }