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