@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.
- package/README.md +6 -2
- package/crates/ckx-sdk/Cargo.toml +1 -1
- package/crates/ckx-sdk/src/kit/econ/holdings.rs +241 -0
- package/crates/ckx-sdk/src/kit/econ/holds.rs +202 -0
- package/crates/ckx-sdk/src/kit/econ/items.rs +212 -0
- package/crates/ckx-sdk/src/kit/econ/loot.rs +116 -0
- package/crates/ckx-sdk/src/kit/econ/mod.rs +50 -0
- package/crates/ckx-sdk/src/kit/econ/orderbook.rs +155 -0
- package/crates/ckx-sdk/src/kit/econ/production.rs +155 -0
- package/crates/ckx-sdk/src/kit/econ/progression.rs +254 -0
- package/crates/ckx-sdk/src/kit/econ/recipes.rs +65 -0
- package/crates/ckx-sdk/src/kit/econ/vendor.rs +116 -0
- package/crates/ckx-sdk/src/kit/econ/wallet.rs +67 -0
- package/crates/ckx-sdk/src/kit/mod.rs +23 -2
- package/crates/ckx-sdk/src/lib.rs +2 -1
- package/dist/cli/src/new.js +2 -0
- package/kit.pin +4 -4
- package/package.json +1 -1
- package/starters/arena/Cargo.toml +1 -1
- package/starters/ckx.json +3 -1
- package/starters/deck/Cargo.toml +1 -1
- package/starters/director/Cargo.toml +1 -1
- package/starters/grid-mod/Cargo.toml +1 -1
- package/starters/instance/Cargo.toml +1 -1
- package/starters/leaderboard/Cargo.toml +1 -1
- package/starters/market/Cargo.toml +14 -0
- package/starters/market/src/lib.rs +1137 -0
- package/starters/matchmaker/Cargo.toml +1 -1
- package/starters/minigame/Cargo.toml +1 -1
- package/starters/npc-mobs/Cargo.toml +1 -1
- package/starters/player/Cargo.toml +14 -0
- package/starters/player/src/lib.rs +994 -0
- package/starters/racing/Cargo.toml +1 -1
- package/starters/session/Cargo.toml +1 -1
- package/starters/tactics/Cargo.toml +1 -1
- package/starters/territory/Cargo.toml +1 -1
- package/starters/world-tick/Cargo.toml +1 -1
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ on the platform: Crowdy Studio, or `execModBuild` then `execModDeploy` (see
|
|
|
37
37
|
|
|
38
38
|
| Command | What it does |
|
|
39
39
|
|---|---|
|
|
40
|
-
| `ckx-kit new <template> <name>` | A crate to start from: `mod`, `client` (a mod's CLIENT half), `hub`, `spoke`, or a starter pack crate (`world-tick`, `matchmaker`, `session`, `npc-mobs`, `leaderboard`, `deck`, `tactics`, `director`, `minigame`, `instance`, `arena`, `racing`, `territory`). |
|
|
40
|
+
| `ckx-kit new <template> <name>` | A crate to start from: `mod`, `client` (a mod's CLIENT half), `hub`, `spoke`, or a starter pack crate (`world-tick`, `matchmaker`, `session`, `npc-mobs`, `leaderboard`, `deck`, `tactics`, `director`, `minigame`, `instance`, `arena`, `racing`, `territory`, `player`, `market`). |
|
|
41
41
|
| `ckx-kit check [dir]` | The platform's source checks, on exactly the files it takes (`Cargo.toml`, `README.md`, `.rs` files under `src/`). Lists what it would not send. |
|
|
42
42
|
| `ckx-kit build [dir]` | The checks, then the pinned Rust for `wasm32-unknown-unknown` with the platform's release profile and the platform's dependency versions, then the module check. `--mod` adds the mod limits. A CLIENT half is metered and optimized as the platform does it, and its capability summary, the list visitors are asked to agree to, is printed with its hash. `--json` prints a report. |
|
|
43
43
|
| `ckx-kit doctor` | What a build here needs, and which platform toolchain this kit matches. |
|
|
@@ -55,7 +55,11 @@ random generator and the realtime event registry. The starter pack crates speak
|
|
|
55
55
|
clients' typed modules drive them as they are. From `ckx-sdk` 0.11.0 it also has the pieces the
|
|
56
56
|
genre starters are made of: `kit::play` (turns, scores, cards, combat, abilities, timing, a
|
|
57
57
|
movement warden for positions clients report), `kit::sim` (day cycle, weather, resource nodes,
|
|
58
|
-
waves, zones) and `kit::ai` (paths, flow fields, steering, behavior trees).
|
|
58
|
+
waves, zones) and `kit::ai` (paths, flow fields, steering, behavior trees); from 0.12.0
|
|
59
|
+
`kit::econ`, the soft economy the `player` and `market` starters keep in hub state: items,
|
|
60
|
+
wallets, holds, references answered once, recipes, loot with pity, production, vendors, an
|
|
61
|
+
order book, levels, achievements and quests. It is not a ledger: no real money, and a transfer
|
|
62
|
+
between hubs is steps, not a transaction.
|
|
59
63
|
|
|
60
64
|
## Test
|
|
61
65
|
|
|
@@ -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
|
+
}
|