@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
|
@@ -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,
|
|
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;
|
package/dist/cli/src/new.js
CHANGED
|
@@ -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.
|
|
4
|
-
toolchain_commit=
|
|
5
|
-
sdk_commit=
|
|
6
|
-
ckx_sdk_version=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.
|
|
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",
|
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
|
}
|
package/starters/deck/Cargo.toml
CHANGED