@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,994 @@
|
|
|
1
|
+
//! A player's own hub, keyed by their player id: what they own and what they have done. Their
|
|
2
|
+
//! inventory and soft currencies, with mail for what does not fit; crafting; loot with pity;
|
|
3
|
+
//! idle production that catches up on the time away; experience and levels; achievements; and
|
|
4
|
+
//! quests, first-session tutorials among them (`ckx_sdk::kit::econ`). It is not a ledger: the
|
|
5
|
+
//! items and balances are this hub's state, and a transfer to another player's hub is steps a
|
|
6
|
+
//! market drives, each answered once by its reference.
|
|
7
|
+
//!
|
|
8
|
+
//! Endpoints for the player whose hub it is: `me` (everything), `inventory`, `wallet`,
|
|
9
|
+
//! `progress`, `craft { recipe, times }`, `open { table }`, `collect`, `accept { quest }`,
|
|
10
|
+
//! `claim { quest }`, `step { counter }` (a counter the config lets the client report),
|
|
11
|
+
//! `discard { item, count }` and `take_mail`. For anyone: `profile` (level and achievements)
|
|
12
|
+
//! and `rules` (the items, recipes, loot tables, quests and achievements). For the app's other
|
|
13
|
+
//! hubs and its developers: `grant { ref, bundle, xp, counters }` and `record { ref, counter, n }`
|
|
14
|
+
//! (rewards and what the player did, from the hub that decided it), and the steps of a transfer:
|
|
15
|
+
//! `hold { ref, bundle }`, `settle { ref, hold, bundle }`, `release { ref, hold }`,
|
|
16
|
+
//! `deposit { ref, bundle }` and `exchange { ref, pay, get }`, each answered once by its `ref`.
|
|
17
|
+
//!
|
|
18
|
+
//! It speaks the kit protocol (`ckx_sdk::kit`): the `player` topic carries the public profile as
|
|
19
|
+
//! `snapshot` messages, `event` messages when the player levels up or unlocks an achievement,
|
|
20
|
+
//! and `changed` messages naming the player (`what`: `inventory`, `wallet`, `progress`) when what
|
|
21
|
+
//! only they read changes; `snapshot { topic: "player" }` answers the profile; the player's
|
|
22
|
+
//! changes may carry an `action_id`; refusals carry `kit:<code>:` codes.
|
|
23
|
+
|
|
24
|
+
use std::collections::BTreeMap;
|
|
25
|
+
|
|
26
|
+
use ckx_sdk::kit::econ::loot::{self, LootBook, LootRoll, LootTable};
|
|
27
|
+
use ckx_sdk::kit::econ::production::Chain;
|
|
28
|
+
use ckx_sdk::kit::econ::progression::{AchievementDef, Levels, Objective, Progress, QuestDef, Record, Unlocks};
|
|
29
|
+
use ckx_sdk::kit::econ::recipes::{self, Recipe};
|
|
30
|
+
use ckx_sdk::kit::econ::{Applied, Bundle, Catalog, Holdings, Holds, ItemDef, Stack};
|
|
31
|
+
use ckx_sdk::kit::{self, Publisher, Receipts, Rng, codes};
|
|
32
|
+
use ckx_sdk::prelude::*;
|
|
33
|
+
use serde_json::json;
|
|
34
|
+
|
|
35
|
+
pub const TOPIC: &str = "player";
|
|
36
|
+
const PUBLISH_MS: u64 = 1_000;
|
|
37
|
+
const INVENTORY: &str = "inventory";
|
|
38
|
+
const WALLET: &str = "wallet";
|
|
39
|
+
const PROGRESS: &str = "progress";
|
|
40
|
+
|
|
41
|
+
/// A loot table and what one roll costs.
|
|
42
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
43
|
+
pub struct LootDef {
|
|
44
|
+
pub id: String,
|
|
45
|
+
#[serde(default)]
|
|
46
|
+
pub cost: Bundle,
|
|
47
|
+
#[serde(flatten)]
|
|
48
|
+
pub table: LootTable,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
52
|
+
#[serde(default)]
|
|
53
|
+
pub struct Config {
|
|
54
|
+
pub slots: u32,
|
|
55
|
+
pub items: Catalog,
|
|
56
|
+
pub currencies: Vec<String>,
|
|
57
|
+
/// What a new player starts with.
|
|
58
|
+
pub start: Bundle,
|
|
59
|
+
pub levels: Levels,
|
|
60
|
+
pub achievements: Vec<AchievementDef>,
|
|
61
|
+
pub quests: Vec<QuestDef>,
|
|
62
|
+
/// Counters the player's client may report itself (`step`): tutorial steps, worth nothing.
|
|
63
|
+
pub client_counters: Vec<String>,
|
|
64
|
+
pub recipes: Vec<Recipe>,
|
|
65
|
+
pub loot: Vec<LootDef>,
|
|
66
|
+
/// The idle producers, their storage, and nothing else: its clock starts with the hub.
|
|
67
|
+
pub production: Chain,
|
|
68
|
+
/// Mixed into the loot seed with what a client cannot see (the instance, when it started),
|
|
69
|
+
/// so nobody can work out a roll before paying for it. Never answered by `rules`.
|
|
70
|
+
pub secret: Option<String>,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fn item(id: &str, max_stack: u64, tradable: bool) -> ItemDef {
|
|
74
|
+
ItemDef { id: id.into(), max_stack, tradable, name: String::new() }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn items(pairs: &[(&str, u64)]) -> Bundle {
|
|
78
|
+
pairs.iter().fold(Bundle::default(), |b, (id, n)| b.plus(&Bundle::item(id, *n)).unwrap_or(b))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fn objective(counter: &str, target: u64) -> Objective {
|
|
82
|
+
Objective { counter: counter.into(), target }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
impl Default for Config {
|
|
86
|
+
fn default() -> Self {
|
|
87
|
+
use ckx_sdk::kit::econ::loot::LootEntry;
|
|
88
|
+
use ckx_sdk::kit::econ::production::Producer;
|
|
89
|
+
Self {
|
|
90
|
+
slots: 30,
|
|
91
|
+
items: Catalog::from(vec![
|
|
92
|
+
item("wood", 99, true),
|
|
93
|
+
item("plank", 99, true),
|
|
94
|
+
item("ore", 99, true),
|
|
95
|
+
item("ingot", 99, true),
|
|
96
|
+
item("sword", 1, true),
|
|
97
|
+
item("potion", 20, true),
|
|
98
|
+
item("relic", 1, false),
|
|
99
|
+
]),
|
|
100
|
+
currencies: vec!["gold".into(), "gems".into()],
|
|
101
|
+
start: Bundle::coins("gold", 100).plus(&Bundle::coins("gems", 20)).unwrap_or_default(),
|
|
102
|
+
levels: Levels::default(),
|
|
103
|
+
achievements: vec![
|
|
104
|
+
AchievementDef {
|
|
105
|
+
id: "first_craft".into(),
|
|
106
|
+
counter: "crafted".into(),
|
|
107
|
+
target: 1,
|
|
108
|
+
reward: Bundle::coins("gold", 10),
|
|
109
|
+
xp: 25,
|
|
110
|
+
hidden: false,
|
|
111
|
+
},
|
|
112
|
+
AchievementDef {
|
|
113
|
+
id: "smith".into(),
|
|
114
|
+
counter: "crafted:sword".into(),
|
|
115
|
+
target: 3,
|
|
116
|
+
reward: Bundle::coins("gems", 5),
|
|
117
|
+
xp: 100,
|
|
118
|
+
hidden: false,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
quests: vec![
|
|
122
|
+
QuestDef {
|
|
123
|
+
id: "welcome".into(),
|
|
124
|
+
objectives: vec![objective("tutorial:inventory", 1), objective("crafted", 1)],
|
|
125
|
+
reward: Bundle::coins("gold", 25),
|
|
126
|
+
xp: 50,
|
|
127
|
+
requires: vec![],
|
|
128
|
+
level: 0,
|
|
129
|
+
tutorial: true,
|
|
130
|
+
},
|
|
131
|
+
QuestDef {
|
|
132
|
+
id: "gatherer".into(),
|
|
133
|
+
objectives: vec![objective("collected", 20)],
|
|
134
|
+
reward: Bundle::item("potion", 3),
|
|
135
|
+
xp: 100,
|
|
136
|
+
requires: vec!["welcome".into()],
|
|
137
|
+
level: 0,
|
|
138
|
+
tutorial: false,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
client_counters: vec!["tutorial:inventory".into(), "tutorial:map".into()],
|
|
142
|
+
recipes: vec![
|
|
143
|
+
Recipe { id: "plank".into(), inputs: Bundle::item("wood", 2), outputs: Bundle::item("plank", 1), level: 0 },
|
|
144
|
+
Recipe {
|
|
145
|
+
id: "ingot".into(),
|
|
146
|
+
inputs: Bundle::item("ore", 3).plus(&Bundle::coins("gold", 1)).unwrap_or_default(),
|
|
147
|
+
outputs: Bundle::item("ingot", 1),
|
|
148
|
+
level: 0,
|
|
149
|
+
},
|
|
150
|
+
Recipe { id: "sword".into(), inputs: items(&[("ingot", 2), ("plank", 1)]), outputs: Bundle::item("sword", 1), level: 2 },
|
|
151
|
+
],
|
|
152
|
+
loot: vec![LootDef {
|
|
153
|
+
id: "chest".into(),
|
|
154
|
+
cost: Bundle::coins("gems", 10),
|
|
155
|
+
table: LootTable {
|
|
156
|
+
entries: vec![
|
|
157
|
+
LootEntry { item: "potion".into(), count: 3, weight: 70, pity_group: None },
|
|
158
|
+
LootEntry { item: "ingot".into(), count: 2, weight: 25, pity_group: None },
|
|
159
|
+
LootEntry { item: "relic".into(), count: 1, weight: 5, pity_group: Some("rare".into()) },
|
|
160
|
+
],
|
|
161
|
+
pity_within: BTreeMap::from([("rare".to_string(), 10)]),
|
|
162
|
+
},
|
|
163
|
+
}],
|
|
164
|
+
production: Chain {
|
|
165
|
+
producers: vec![
|
|
166
|
+
Producer { id: "lumber".into(), outputs: BTreeMap::from([("wood".into(), 1)]), per_minute: 2.0, ..Producer::default() },
|
|
167
|
+
Producer { id: "mine".into(), outputs: BTreeMap::from([("ore".into(), 1)]), per_minute: 1.0, ..Producer::default() },
|
|
168
|
+
],
|
|
169
|
+
capacity: BTreeMap::from([("wood".into(), 120), ("ore".into(), 60)]),
|
|
170
|
+
..Chain::default()
|
|
171
|
+
},
|
|
172
|
+
secret: None,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
impl Config {
|
|
178
|
+
/// Every item a config names is in its catalog, and every currency in its list.
|
|
179
|
+
fn check(&self) -> Result<()> {
|
|
180
|
+
let bundles = self
|
|
181
|
+
.recipes
|
|
182
|
+
.iter()
|
|
183
|
+
.flat_map(|r| [&r.inputs, &r.outputs])
|
|
184
|
+
.chain(self.loot.iter().map(|l| &l.cost))
|
|
185
|
+
.chain(self.achievements.iter().map(|a| &a.reward))
|
|
186
|
+
.chain(self.quests.iter().map(|q| &q.reward))
|
|
187
|
+
.chain(std::iter::once(&self.start));
|
|
188
|
+
for b in bundles {
|
|
189
|
+
self.check_bundle(b)?;
|
|
190
|
+
}
|
|
191
|
+
let produced = self.production.producers.iter().flat_map(|p| p.outputs.keys().chain(p.inputs.keys()));
|
|
192
|
+
let looted = self.loot.iter().flat_map(|l| l.table.entries.iter().map(|e| &e.item));
|
|
193
|
+
for id in produced.chain(looted) {
|
|
194
|
+
self.items.item(id)?;
|
|
195
|
+
}
|
|
196
|
+
Ok(())
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
fn check_bundle(&self, b: &Bundle) -> Result<()> {
|
|
200
|
+
b.check()?;
|
|
201
|
+
for id in b.items.keys() {
|
|
202
|
+
self.items.item(id)?;
|
|
203
|
+
}
|
|
204
|
+
match b.currency.keys().find(|c| !self.currencies.contains(c)) {
|
|
205
|
+
Some(c) => Err(kit::error(codes::BAD_REQUEST, format!("there is no currency `{c}`"))),
|
|
206
|
+
None => Ok(()),
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- the views ----
|
|
212
|
+
|
|
213
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
214
|
+
pub struct ProfileView {
|
|
215
|
+
pub player: u64,
|
|
216
|
+
pub level: u32,
|
|
217
|
+
pub achievements: Vec<String>,
|
|
218
|
+
pub quests_done: usize,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
222
|
+
pub struct InventoryView {
|
|
223
|
+
pub slots: u32,
|
|
224
|
+
pub stacks: Vec<Stack>,
|
|
225
|
+
/// Items given that did not fit, waiting for `take_mail`.
|
|
226
|
+
pub mail: BTreeMap<String, u64>,
|
|
227
|
+
/// Items held for a trade or an order.
|
|
228
|
+
pub held: BTreeMap<String, u64>,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
232
|
+
pub struct WalletView {
|
|
233
|
+
pub balances: BTreeMap<String, i64>,
|
|
234
|
+
/// Currency held for a trade or an order.
|
|
235
|
+
pub held: BTreeMap<String, i64>,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
239
|
+
pub struct ObjectiveView {
|
|
240
|
+
pub counter: String,
|
|
241
|
+
pub target: u64,
|
|
242
|
+
pub count: u64,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
246
|
+
pub struct QuestView {
|
|
247
|
+
pub id: String,
|
|
248
|
+
/// `locked`, `available`, `active`, `complete` (its reward waits) or `done`.
|
|
249
|
+
pub state: String,
|
|
250
|
+
pub objectives: Vec<ObjectiveView>,
|
|
251
|
+
pub reward: Bundle,
|
|
252
|
+
pub xp: u64,
|
|
253
|
+
pub tutorial: bool,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
257
|
+
pub struct AchievementView {
|
|
258
|
+
pub id: String,
|
|
259
|
+
pub counter: String,
|
|
260
|
+
pub target: u64,
|
|
261
|
+
pub count: u64,
|
|
262
|
+
pub unlocked_at: Option<u64>,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
266
|
+
pub struct ProgressView {
|
|
267
|
+
pub level: u32,
|
|
268
|
+
pub xp: u64,
|
|
269
|
+
/// What the level takes to finish; none at the top.
|
|
270
|
+
pub next: Option<u64>,
|
|
271
|
+
pub total_xp: u64,
|
|
272
|
+
pub achievements: Vec<AchievementView>,
|
|
273
|
+
pub quests: Vec<QuestView>,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
277
|
+
pub struct ProductionView {
|
|
278
|
+
pub producers: Vec<(String, f64)>,
|
|
279
|
+
pub stock: BTreeMap<String, u64>,
|
|
280
|
+
pub capacity: BTreeMap<String, u64>,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
284
|
+
pub struct MeView {
|
|
285
|
+
pub profile: ProfileView,
|
|
286
|
+
pub inventory: InventoryView,
|
|
287
|
+
pub wallet: WalletView,
|
|
288
|
+
pub progress: ProgressView,
|
|
289
|
+
pub production: ProductionView,
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/// What one change of the player's earned: levels, achievements, quests complete.
|
|
293
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
|
294
|
+
pub struct Earned {
|
|
295
|
+
pub levels: u32,
|
|
296
|
+
pub achievements: Vec<String>,
|
|
297
|
+
pub quests: Vec<String>,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
301
|
+
pub struct Crafted {
|
|
302
|
+
pub made: Bundle,
|
|
303
|
+
pub earned: Earned,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
307
|
+
pub struct Opened {
|
|
308
|
+
pub roll: LootRoll,
|
|
309
|
+
pub earned: Earned,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---- the arguments ----
|
|
313
|
+
|
|
314
|
+
#[derive(Deserialize)]
|
|
315
|
+
struct Craft {
|
|
316
|
+
recipe: String,
|
|
317
|
+
#[serde(default = "one")]
|
|
318
|
+
times: u64,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
fn one() -> u64 {
|
|
322
|
+
1
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#[derive(Deserialize)]
|
|
326
|
+
struct Named {
|
|
327
|
+
#[serde(alias = "table", alias = "quest", alias = "counter")]
|
|
328
|
+
id: String,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#[derive(Deserialize)]
|
|
332
|
+
struct Discard {
|
|
333
|
+
item: String,
|
|
334
|
+
count: u64,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#[derive(Deserialize, Default)]
|
|
338
|
+
#[serde(default)]
|
|
339
|
+
struct Grant {
|
|
340
|
+
r#ref: String,
|
|
341
|
+
bundle: Bundle,
|
|
342
|
+
xp: u64,
|
|
343
|
+
counters: BTreeMap<String, u64>,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#[derive(Deserialize)]
|
|
347
|
+
struct Count {
|
|
348
|
+
r#ref: String,
|
|
349
|
+
counter: String,
|
|
350
|
+
#[serde(default = "one")]
|
|
351
|
+
n: u64,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#[derive(Deserialize)]
|
|
355
|
+
struct Hold {
|
|
356
|
+
r#ref: String,
|
|
357
|
+
bundle: Bundle,
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#[derive(Deserialize)]
|
|
361
|
+
struct Settle {
|
|
362
|
+
r#ref: String,
|
|
363
|
+
hold: String,
|
|
364
|
+
bundle: Bundle,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#[derive(Deserialize)]
|
|
368
|
+
struct Release {
|
|
369
|
+
r#ref: String,
|
|
370
|
+
hold: String,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
#[derive(Deserialize)]
|
|
374
|
+
struct Exchange {
|
|
375
|
+
r#ref: String,
|
|
376
|
+
pay: Bundle,
|
|
377
|
+
get: Bundle,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---- the hub ----
|
|
381
|
+
|
|
382
|
+
#[derive(Serialize, Deserialize, Debug)]
|
|
383
|
+
pub struct PlayerHub {
|
|
384
|
+
pub config: Config,
|
|
385
|
+
pub owner: u64,
|
|
386
|
+
pub holdings: Holdings,
|
|
387
|
+
#[serde(default)]
|
|
388
|
+
pub holds: Holds,
|
|
389
|
+
#[serde(default)]
|
|
390
|
+
pub applied: Applied,
|
|
391
|
+
pub progress: Progress,
|
|
392
|
+
#[serde(default)]
|
|
393
|
+
pub record: Record,
|
|
394
|
+
#[serde(default)]
|
|
395
|
+
pub books: BTreeMap<String, LootBook>,
|
|
396
|
+
pub rng: Rng,
|
|
397
|
+
pub chain: Chain,
|
|
398
|
+
#[serde(default)]
|
|
399
|
+
pub kit: Publisher,
|
|
400
|
+
#[serde(default)]
|
|
401
|
+
pub receipts: Receipts,
|
|
402
|
+
#[serde(default)]
|
|
403
|
+
pub dirty: bool,
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
impl PlayerHub {
|
|
407
|
+
/// A new player's hub. `entropy` seeds its loot: something no client can see.
|
|
408
|
+
pub fn new(config: Config, owner: u64, now: u64, entropy: u64) -> Result<Self> {
|
|
409
|
+
config.check()?;
|
|
410
|
+
let mut holdings = Holdings::new(config.slots);
|
|
411
|
+
holdings.give_or_mail(&config.items, &config.start)?;
|
|
412
|
+
let mut record = Record::default();
|
|
413
|
+
for q in config.quests.iter().filter(|q| q.tutorial) {
|
|
414
|
+
record.accept(q, 1)?;
|
|
415
|
+
}
|
|
416
|
+
let mut chain = config.production.clone();
|
|
417
|
+
chain.at = 0;
|
|
418
|
+
chain.start(now);
|
|
419
|
+
Ok(Self {
|
|
420
|
+
owner,
|
|
421
|
+
holdings,
|
|
422
|
+
holds: Holds::default(),
|
|
423
|
+
applied: Applied::default(),
|
|
424
|
+
progress: Progress::default(),
|
|
425
|
+
record,
|
|
426
|
+
books: BTreeMap::new(),
|
|
427
|
+
rng: Rng::seeded(Rng::seed_from(&format!("player:{owner}:{entropy}:{}", config.secret.as_deref().unwrap_or("")))),
|
|
428
|
+
chain,
|
|
429
|
+
kit: Publisher::default(),
|
|
430
|
+
receipts: Receipts::default(),
|
|
431
|
+
dirty: true,
|
|
432
|
+
config,
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
pub fn profile(&self) -> ProfileView {
|
|
437
|
+
ProfileView {
|
|
438
|
+
player: self.owner,
|
|
439
|
+
level: self.progress.level,
|
|
440
|
+
achievements: self.record.unlocked.keys().cloned().collect(),
|
|
441
|
+
quests_done: self.record.done.len(),
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
pub fn inventory(&self) -> InventoryView {
|
|
446
|
+
let held = self.holds.total();
|
|
447
|
+
InventoryView {
|
|
448
|
+
slots: self.holdings.inventory.slots,
|
|
449
|
+
stacks: self.holdings.inventory.stacks.clone(),
|
|
450
|
+
mail: self.holdings.mail.clone(),
|
|
451
|
+
held: held.items,
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
pub fn wallet(&self) -> WalletView {
|
|
456
|
+
WalletView { balances: self.holdings.wallet.balances.clone(), held: self.holds.total().currency }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
pub fn progress(&self) -> ProgressView {
|
|
460
|
+
let r = &self.record;
|
|
461
|
+
let achievements = self
|
|
462
|
+
.config
|
|
463
|
+
.achievements
|
|
464
|
+
.iter()
|
|
465
|
+
.filter(|a| !a.hidden || r.unlocked.contains_key(&a.id))
|
|
466
|
+
.map(|a| AchievementView {
|
|
467
|
+
id: a.id.clone(),
|
|
468
|
+
counter: a.counter.clone(),
|
|
469
|
+
target: a.target,
|
|
470
|
+
count: r.counters.get(&a.counter).copied().unwrap_or(0).min(a.target),
|
|
471
|
+
unlocked_at: r.unlocked.get(&a.id).copied(),
|
|
472
|
+
})
|
|
473
|
+
.collect();
|
|
474
|
+
let quests = self
|
|
475
|
+
.config
|
|
476
|
+
.quests
|
|
477
|
+
.iter()
|
|
478
|
+
.map(|q| {
|
|
479
|
+
let counts = r.active.get(&q.id);
|
|
480
|
+
let state = if r.done.contains_key(&q.id) {
|
|
481
|
+
"done"
|
|
482
|
+
} else if r.complete.contains(&q.id) {
|
|
483
|
+
"complete"
|
|
484
|
+
} else if counts.is_some() {
|
|
485
|
+
"active"
|
|
486
|
+
} else if q.requires.iter().all(|x| r.done.contains_key(x)) && self.progress.level >= q.level {
|
|
487
|
+
"available"
|
|
488
|
+
} else {
|
|
489
|
+
"locked"
|
|
490
|
+
};
|
|
491
|
+
let finished = matches!(state, "done" | "complete");
|
|
492
|
+
QuestView {
|
|
493
|
+
id: q.id.clone(),
|
|
494
|
+
state: state.into(),
|
|
495
|
+
objectives: q
|
|
496
|
+
.objectives
|
|
497
|
+
.iter()
|
|
498
|
+
.enumerate()
|
|
499
|
+
.map(|(i, o)| ObjectiveView {
|
|
500
|
+
counter: o.counter.clone(),
|
|
501
|
+
target: o.target,
|
|
502
|
+
count: if finished { o.target } else { counts.and_then(|c| c.get(i)).copied().unwrap_or(0) },
|
|
503
|
+
})
|
|
504
|
+
.collect(),
|
|
505
|
+
reward: q.reward.clone(),
|
|
506
|
+
xp: q.xp,
|
|
507
|
+
tutorial: q.tutorial,
|
|
508
|
+
}
|
|
509
|
+
})
|
|
510
|
+
.collect();
|
|
511
|
+
ProgressView {
|
|
512
|
+
level: self.progress.level,
|
|
513
|
+
xp: self.progress.xp,
|
|
514
|
+
next: self.config.levels.to_next(self.progress.level),
|
|
515
|
+
total_xp: self.progress.total_xp,
|
|
516
|
+
achievements,
|
|
517
|
+
quests,
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
pub fn production(&self) -> ProductionView {
|
|
522
|
+
ProductionView {
|
|
523
|
+
producers: self.chain.producers.iter().map(|p| (p.id.clone(), p.per_minute)).collect(),
|
|
524
|
+
stock: self.chain.stock.clone(),
|
|
525
|
+
capacity: self.chain.capacity.clone(),
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
pub fn me(&self) -> MeView {
|
|
530
|
+
MeView {
|
|
531
|
+
profile: self.profile(),
|
|
532
|
+
inventory: self.inventory(),
|
|
533
|
+
wallet: self.wallet(),
|
|
534
|
+
progress: self.progress(),
|
|
535
|
+
production: self.production(),
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/// Counts what the player did, and gives what it earned.
|
|
540
|
+
fn count(&mut self, counter: &str, n: u64, now: u64, earned: &mut Earned) -> Result<()> {
|
|
541
|
+
let Unlocks { achievements, quests } = self.record.count(&self.config.achievements, &self.config.quests, counter, n, now);
|
|
542
|
+
for id in &achievements {
|
|
543
|
+
if let Some(a) = self.config.achievements.iter().find(|a| &a.id == id).cloned() {
|
|
544
|
+
self.holdings.give_or_mail(&self.config.items, &a.reward)?;
|
|
545
|
+
self.gain(a.xp, earned);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
earned.achievements.extend(achievements);
|
|
549
|
+
earned.quests.extend(quests);
|
|
550
|
+
Ok(())
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
fn gain(&mut self, xp: u64, earned: &mut Earned) {
|
|
554
|
+
earned.levels += self.progress.gain(&self.config.levels, xp);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
fn owner_of(&self, call: &Call<'_>) -> Result<()> {
|
|
558
|
+
match call.caller {
|
|
559
|
+
Caller::Player(p) if p == self.owner => Ok(()),
|
|
560
|
+
Caller::Developer(_) => Ok(()),
|
|
561
|
+
_ => Err(kit::error(codes::FORBIDDEN, "this is another player's")),
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/// What the player asked for, run once per action id.
|
|
566
|
+
fn act(&mut self, call: &Call<'_>, now: u64, earned: &mut Earned) -> Result<Vec<u8>> {
|
|
567
|
+
self.owner_of(call)?;
|
|
568
|
+
let items = self.config.items.clone();
|
|
569
|
+
match call.method {
|
|
570
|
+
"craft" => {
|
|
571
|
+
let c: Craft = kit::args(call)?;
|
|
572
|
+
let recipe = self.config.recipes.iter().find(|r| r.id == c.recipe).cloned();
|
|
573
|
+
let recipe = recipe.ok_or_else(|| kit::error(codes::NOT_FOUND, format!("there is no recipe `{}`", c.recipe)))?;
|
|
574
|
+
let made = recipes::craft(&mut self.holdings, &items, &recipe, c.times, self.progress.level)?;
|
|
575
|
+
self.count("crafted", c.times, now, earned)?;
|
|
576
|
+
self.count(&format!("crafted:{}", recipe.id), c.times, now, earned)?;
|
|
577
|
+
encode(&Crafted { made, earned: earned.clone() })
|
|
578
|
+
}
|
|
579
|
+
"open" => {
|
|
580
|
+
let Named { id } = kit::args(call)?;
|
|
581
|
+
let def = self.config.loot.iter().find(|l| l.id == id).cloned();
|
|
582
|
+
let def = def.ok_or_else(|| kit::error(codes::NOT_FOUND, format!("there is no loot table `{id}`")))?;
|
|
583
|
+
let (mut book, mut rng) = (self.books.get(&id).cloned().unwrap_or_default(), self.rng);
|
|
584
|
+
// The hub's own state, and the moment it rolls, decide it.
|
|
585
|
+
let mut dice = Rng::seeded(rng.next_u64() ^ now.wrapping_mul(0x9e37_79b9_7f4a_7c15));
|
|
586
|
+
let roll = loot::roll(&def.table, &mut book, &mut dice)
|
|
587
|
+
.ok_or_else(|| kit::error(codes::EMPTY, format!("`{id}` has nothing to roll")))?;
|
|
588
|
+
self.holdings.swap(&items, &def.cost, &Bundle::item(&roll.item, roll.count))?;
|
|
589
|
+
self.books.insert(id.clone(), book);
|
|
590
|
+
self.rng = rng;
|
|
591
|
+
self.count(&format!("opened:{id}"), 1, now, earned)?;
|
|
592
|
+
encode(&Opened { roll, earned: earned.clone() })
|
|
593
|
+
}
|
|
594
|
+
"collect" => {
|
|
595
|
+
self.chain.advance(now);
|
|
596
|
+
let mut collected = Bundle::default();
|
|
597
|
+
for (resource, have) in self.chain.stock.clone() {
|
|
598
|
+
let fits = have.min(self.holdings.inventory.room(&items, &resource));
|
|
599
|
+
if fits > 0 {
|
|
600
|
+
self.holdings.inventory.add(&items, &resource, fits)?;
|
|
601
|
+
self.chain.take(&resource, fits);
|
|
602
|
+
collected = collected.plus(&Bundle::item(&resource, fits))?;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
let total: u64 = collected.items.values().sum();
|
|
606
|
+
if total > 0 {
|
|
607
|
+
self.count("collected", total, now, earned)?;
|
|
608
|
+
}
|
|
609
|
+
encode(&collected)
|
|
610
|
+
}
|
|
611
|
+
"accept" => {
|
|
612
|
+
let Named { id } = kit::args(call)?;
|
|
613
|
+
let q = self.config.quests.iter().find(|q| q.id == id).cloned();
|
|
614
|
+
let q = q.ok_or_else(|| kit::error(codes::NOT_FOUND, format!("there is no quest `{id}`")))?;
|
|
615
|
+
self.record.accept(&q, self.progress.level)?;
|
|
616
|
+
encode(&true)
|
|
617
|
+
}
|
|
618
|
+
"claim" => {
|
|
619
|
+
let Named { id } = kit::args(call)?;
|
|
620
|
+
let q = self.config.quests.iter().find(|q| q.id == id).cloned();
|
|
621
|
+
let q = q.ok_or_else(|| kit::error(codes::NOT_FOUND, format!("there is no quest `{id}`")))?;
|
|
622
|
+
let mut next = self.holdings.clone();
|
|
623
|
+
next.give(&items, &q.reward)?;
|
|
624
|
+
self.record.claim(&id, now)?;
|
|
625
|
+
self.holdings = next;
|
|
626
|
+
self.gain(q.xp, earned);
|
|
627
|
+
self.count(&format!("quest:{id}"), 1, now, earned)?;
|
|
628
|
+
encode(&q.reward)
|
|
629
|
+
}
|
|
630
|
+
"step" => {
|
|
631
|
+
let Named { id } = kit::args(call)?;
|
|
632
|
+
if !self.config.client_counters.contains(&id) {
|
|
633
|
+
return Err(kit::error(codes::FORBIDDEN, format!("`{id}` is counted by the game's hubs, not by a client")));
|
|
634
|
+
}
|
|
635
|
+
self.count(&id, 1, now, earned)?;
|
|
636
|
+
encode(&earned.clone())
|
|
637
|
+
}
|
|
638
|
+
"discard" => {
|
|
639
|
+
let d: Discard = kit::args(call)?;
|
|
640
|
+
self.holdings.take(&Bundle::item(&d.item, d.count))?;
|
|
641
|
+
encode(&true)
|
|
642
|
+
}
|
|
643
|
+
"take_mail" => encode(&self.holdings.take_mail(&items)),
|
|
644
|
+
other => Err(Error::unknown_method(other)),
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/// What another hub (or a developer) asked for, run once per reference.
|
|
649
|
+
fn trusted(&mut self, call: &Call<'_>, now: u64, earned: &mut Earned) -> Result<Vec<u8>> {
|
|
650
|
+
let items = self.config.items.clone();
|
|
651
|
+
match call.method {
|
|
652
|
+
"grant" => {
|
|
653
|
+
let g: Grant = kit::args(call)?;
|
|
654
|
+
Applied::run(
|
|
655
|
+
self,
|
|
656
|
+
|h| &mut h.applied,
|
|
657
|
+
&g.r#ref,
|
|
658
|
+
now,
|
|
659
|
+
|h| {
|
|
660
|
+
h.config.check_bundle(&g.bundle)?;
|
|
661
|
+
h.holdings.give_or_mail(&items, &g.bundle)?;
|
|
662
|
+
h.gain(g.xp, earned);
|
|
663
|
+
for (counter, n) in &g.counters {
|
|
664
|
+
h.count(counter, *n, now, earned)?;
|
|
665
|
+
}
|
|
666
|
+
encode(&earned.clone())
|
|
667
|
+
},
|
|
668
|
+
)
|
|
669
|
+
}
|
|
670
|
+
"record" => {
|
|
671
|
+
let c: Count = kit::args(call)?;
|
|
672
|
+
Applied::run(
|
|
673
|
+
self,
|
|
674
|
+
|h| &mut h.applied,
|
|
675
|
+
&c.r#ref,
|
|
676
|
+
now,
|
|
677
|
+
|h| {
|
|
678
|
+
h.count(&c.counter, c.n, now, earned)?;
|
|
679
|
+
encode(&earned.clone())
|
|
680
|
+
},
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
"hold" => {
|
|
684
|
+
let h: Hold = kit::args(call)?;
|
|
685
|
+
if let Some(id) = h.bundle.items.keys().find(|id| items.get(id).is_some_and(|d| !d.tradable)) {
|
|
686
|
+
return Err(kit::error(codes::FORBIDDEN, format!("`{id}` does not change hands")));
|
|
687
|
+
}
|
|
688
|
+
Applied::run(
|
|
689
|
+
self,
|
|
690
|
+
|s| &mut s.applied,
|
|
691
|
+
&h.r#ref,
|
|
692
|
+
now,
|
|
693
|
+
|s| {
|
|
694
|
+
s.config.check_bundle(&h.bundle)?;
|
|
695
|
+
s.holds.hold(&mut s.holdings, &h.r#ref, &h.bundle)?;
|
|
696
|
+
encode(&true)
|
|
697
|
+
},
|
|
698
|
+
)
|
|
699
|
+
}
|
|
700
|
+
"settle" => {
|
|
701
|
+
let s: Settle = kit::args(call)?;
|
|
702
|
+
Applied::run(
|
|
703
|
+
self,
|
|
704
|
+
|h| &mut h.applied,
|
|
705
|
+
&s.r#ref,
|
|
706
|
+
now,
|
|
707
|
+
|h| {
|
|
708
|
+
h.holds.settle(&s.hold, &s.bundle)?;
|
|
709
|
+
encode(&true)
|
|
710
|
+
},
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
"release" => {
|
|
714
|
+
let r: Release = kit::args(call)?;
|
|
715
|
+
Applied::run(self, |h| &mut h.applied, &r.r#ref, now, |h| encode(&h.holds.release(&mut h.holdings, &items, &r.hold)?))
|
|
716
|
+
}
|
|
717
|
+
"deposit" => {
|
|
718
|
+
let d: Hold = kit::args(call)?;
|
|
719
|
+
Applied::run(
|
|
720
|
+
self,
|
|
721
|
+
|h| &mut h.applied,
|
|
722
|
+
&d.r#ref,
|
|
723
|
+
now,
|
|
724
|
+
|h| {
|
|
725
|
+
h.config.check_bundle(&d.bundle)?;
|
|
726
|
+
h.holdings.give_or_mail(&items, &d.bundle)?;
|
|
727
|
+
encode(&true)
|
|
728
|
+
},
|
|
729
|
+
)
|
|
730
|
+
}
|
|
731
|
+
"exchange" => {
|
|
732
|
+
let x: Exchange = kit::args(call)?;
|
|
733
|
+
Applied::run(
|
|
734
|
+
self,
|
|
735
|
+
|h| &mut h.applied,
|
|
736
|
+
&x.r#ref,
|
|
737
|
+
now,
|
|
738
|
+
|h| {
|
|
739
|
+
h.config.check_bundle(&x.pay)?;
|
|
740
|
+
h.config.check_bundle(&x.get)?;
|
|
741
|
+
h.holdings.swap(&items, &x.pay, &x.get)?;
|
|
742
|
+
encode(&true)
|
|
743
|
+
},
|
|
744
|
+
)
|
|
745
|
+
}
|
|
746
|
+
other => Err(Error::unknown_method(other)),
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/// Tells the player what changed, and anyone watching what they earned.
|
|
751
|
+
fn after(&mut self, ctx: &Ctx, earned: &Earned, whats: &[&str]) -> Result<()> {
|
|
752
|
+
for what in whats {
|
|
753
|
+
self.kit.changed(ctx, TOPIC, &[self.owner], what)?;
|
|
754
|
+
}
|
|
755
|
+
if earned.levels > 0 {
|
|
756
|
+
self.kit.event(ctx, TOPIC, &json!({ "type": "level", "player": self.owner, "level": self.progress.level }))?;
|
|
757
|
+
}
|
|
758
|
+
for id in &earned.achievements {
|
|
759
|
+
self.kit.event(ctx, TOPIC, &json!({ "type": "achievement", "player": self.owner, "achievement": id }))?;
|
|
760
|
+
}
|
|
761
|
+
if earned.levels > 0 || !earned.achievements.is_empty() || !earned.quests.is_empty() {
|
|
762
|
+
self.dirty = true;
|
|
763
|
+
}
|
|
764
|
+
Ok(())
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/// What each method can change, for the `changed` messages after it.
|
|
769
|
+
fn touches(method: &str) -> &'static [&'static str] {
|
|
770
|
+
match method {
|
|
771
|
+
"craft" | "open" | "claim" | "grant" => &[INVENTORY, WALLET, PROGRESS],
|
|
772
|
+
"collect" => &[INVENTORY, PROGRESS],
|
|
773
|
+
"accept" | "step" | "record" => &[PROGRESS, INVENTORY, WALLET],
|
|
774
|
+
"discard" | "take_mail" => &[INVENTORY],
|
|
775
|
+
"hold" | "settle" | "release" | "deposit" | "exchange" => &[INVENTORY, WALLET],
|
|
776
|
+
_ => &[],
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
impl Hub for PlayerHub {
|
|
781
|
+
fn spawn(ctx: &Ctx, seed: &[u8]) -> Result<Self> {
|
|
782
|
+
let owner: u64 = ctx.key.parse().map_err(|_| kit::error(codes::BAD_REQUEST, "a player hub's key is its player's id"))?;
|
|
783
|
+
let config = if seed.is_empty() {
|
|
784
|
+
Config::default()
|
|
785
|
+
} else {
|
|
786
|
+
serde_json::from_slice(seed).map_err(|e| Error::new(format!("seed is not a JSON config: {e}")))?
|
|
787
|
+
};
|
|
788
|
+
ctx.timer_every("publish", PUBLISH_MS)?;
|
|
789
|
+
PlayerHub::new(config, owner, ctx.now_ms(), ctx.instance ^ ctx.now_ms().rotate_left(29))
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
|
|
793
|
+
decode(snapshot)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
|
|
797
|
+
encode(self)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
801
|
+
let now = ctx.now_ms();
|
|
802
|
+
match call.method {
|
|
803
|
+
"profile" => return encode(&self.profile()),
|
|
804
|
+
"rules" => {
|
|
805
|
+
let config = &self.config;
|
|
806
|
+
return encode(&json!({
|
|
807
|
+
"slots": config.slots,
|
|
808
|
+
"items": config.items,
|
|
809
|
+
"currencies": config.currencies,
|
|
810
|
+
"recipes": config.recipes,
|
|
811
|
+
"loot": config.loot,
|
|
812
|
+
"quests": config.quests,
|
|
813
|
+
"achievements": config.achievements.iter().filter(|a| !a.hidden).collect::<Vec<_>>(),
|
|
814
|
+
"client_counters": config.client_counters,
|
|
815
|
+
}));
|
|
816
|
+
}
|
|
817
|
+
kit::SNAPSHOT_METHOD => return self.kit.snapshot_reply(ctx, TOPIC, &self.profile()),
|
|
818
|
+
"me" | "inventory" | "wallet" | "progress" => {
|
|
819
|
+
self.owner_of(&call)?;
|
|
820
|
+
return match call.method {
|
|
821
|
+
"me" => {
|
|
822
|
+
self.chain.advance(now);
|
|
823
|
+
encode(&self.me())
|
|
824
|
+
}
|
|
825
|
+
"inventory" => encode(&self.inventory()),
|
|
826
|
+
"wallet" => encode(&self.wallet()),
|
|
827
|
+
_ => encode(&self.progress()),
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
_ => {}
|
|
831
|
+
}
|
|
832
|
+
let mut earned = Earned::default();
|
|
833
|
+
let result = match call.caller {
|
|
834
|
+
Caller::Instance(_) => self.trusted(&call, now, &mut earned),
|
|
835
|
+
Caller::Developer(_) if matches!(call.method, "grant" | "record" | "deposit") => self.trusted(&call, now, &mut earned),
|
|
836
|
+
Caller::Player(_) if matches!(call.method, "grant" | "record" | "hold" | "settle" | "release" | "deposit" | "exchange") => {
|
|
837
|
+
Err(kit::error(codes::FORBIDDEN, format!("`{}` comes from the game's hubs", call.method)))
|
|
838
|
+
}
|
|
839
|
+
_ => Receipts::run(self, |h| &mut h.receipts, &call, now, |h| h.act(&call, now, &mut earned)),
|
|
840
|
+
}?;
|
|
841
|
+
self.after(ctx, &earned, touches(call.method))?;
|
|
842
|
+
Ok(result)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
|
|
846
|
+
if timer == "publish" && self.dirty {
|
|
847
|
+
self.dirty = false;
|
|
848
|
+
self.kit.snapshot(ctx, TOPIC, &self.profile())?;
|
|
849
|
+
}
|
|
850
|
+
Ok(())
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
ckx_sdk::export_hub!(PlayerHub);
|
|
855
|
+
|
|
856
|
+
#[cfg(test)]
|
|
857
|
+
mod tests {
|
|
858
|
+
use super::*;
|
|
859
|
+
use ckx_sdk::kit::{Changed, Message, error_code};
|
|
860
|
+
use ckx_sdk::testing::TestHub;
|
|
861
|
+
|
|
862
|
+
fn code(e: &ckx_sdk::CallError) -> Option<&str> {
|
|
863
|
+
error_code(&e.message).map(|c| c.0)
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
fn hub() -> TestHub<PlayerHub> {
|
|
867
|
+
TestHub::<PlayerHub>::spawn("player", "7").unwrap()
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
#[test]
|
|
871
|
+
fn a_new_player_starts_with_the_config_and_its_tutorial() {
|
|
872
|
+
let mut h = hub();
|
|
873
|
+
let me: MeView = h.call_typed(Caller::Player(7), "me", &()).unwrap();
|
|
874
|
+
assert_eq!(me.wallet.balances["gold"], 100);
|
|
875
|
+
assert_eq!(me.progress.level, 1);
|
|
876
|
+
let welcome = me.progress.quests.iter().find(|q| q.id == "welcome").unwrap();
|
|
877
|
+
assert_eq!(welcome.state, "active", "a tutorial is taken on as the player starts");
|
|
878
|
+
assert_eq!(me.progress.quests.iter().find(|q| q.id == "gatherer").unwrap().state, "locked");
|
|
879
|
+
let e = h.call(Caller::Player(8), "me", &[]).unwrap_err();
|
|
880
|
+
assert_eq!(code(&e), Some(codes::FORBIDDEN), "only its player reads it");
|
|
881
|
+
let p: ProfileView = h.call_typed(Caller::Player(8), "profile", &()).unwrap();
|
|
882
|
+
assert_eq!((p.player, p.level), (7, 1));
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
#[test]
|
|
886
|
+
fn crafting_counts_toward_achievements_and_quests_and_the_reward_is_claimed_once() {
|
|
887
|
+
let mut h = hub();
|
|
888
|
+
let _: bool =
|
|
889
|
+
h.call_typed(Caller::Developer(1), "deposit", &json!({ "ref": "seed-wood", "bundle": { "items": { "wood": 4 } } })).unwrap();
|
|
890
|
+
let crafted: Crafted =
|
|
891
|
+
h.call_typed(Caller::Player(7), "craft", &json!({ "recipe": "plank", "times": 2, "action_id": "c1" })).unwrap();
|
|
892
|
+
assert_eq!(crafted.made, Bundle::item("plank", 2));
|
|
893
|
+
assert_eq!(crafted.earned.achievements, vec!["first_craft".to_string()]);
|
|
894
|
+
let again: Crafted =
|
|
895
|
+
h.call_typed(Caller::Player(7), "craft", &json!({ "recipe": "plank", "times": 2, "action_id": "c1" })).unwrap();
|
|
896
|
+
assert_eq!(again, crafted, "a resent craft is answered, not made again");
|
|
897
|
+
let e = h.call(Caller::Player(7), "craft", &encode(&json!({ "recipe": "plank" })).unwrap()).unwrap_err();
|
|
898
|
+
assert_eq!(code(&e), Some(codes::INSUFFICIENT));
|
|
899
|
+
let e = h.call(Caller::Player(7), "step", &encode(&json!({ "counter": "crafted" })).unwrap()).unwrap_err();
|
|
900
|
+
assert_eq!(code(&e), Some(codes::FORBIDDEN), "a client reports only its own counters");
|
|
901
|
+
let earned: Earned = h.call_typed(Caller::Player(7), "step", &json!({ "counter": "tutorial:inventory" })).unwrap();
|
|
902
|
+
assert_eq!(earned.quests, vec!["welcome".to_string()]);
|
|
903
|
+
let reward: Bundle = h.call_typed(Caller::Player(7), "claim", &json!({ "quest": "welcome" })).unwrap();
|
|
904
|
+
assert_eq!(reward, Bundle::coins("gold", 25));
|
|
905
|
+
let e = h.call(Caller::Player(7), "claim", &encode(&json!({ "quest": "welcome" })).unwrap()).unwrap_err();
|
|
906
|
+
assert_eq!(code(&e), Some(codes::NOT_READY));
|
|
907
|
+
let me: MeView = h.call_typed(Caller::Player(7), "me", &()).unwrap();
|
|
908
|
+
assert_eq!(me.wallet.balances["gold"], 135, "100, the achievement's 10 and the quest's 25");
|
|
909
|
+
assert_eq!(me.progress.total_xp, 75);
|
|
910
|
+
assert_eq!(me.progress.quests.iter().find(|q| q.id == "gatherer").unwrap().state, "available");
|
|
911
|
+
let changed: Vec<Message<Changed>> = h
|
|
912
|
+
.published()
|
|
913
|
+
.iter()
|
|
914
|
+
.filter_map(|(_, m)| decode::<Message<Changed>>(m).ok())
|
|
915
|
+
.filter(|m| m.header.kind == kit::MessageKind::Changed)
|
|
916
|
+
.collect();
|
|
917
|
+
assert!(!changed.is_empty());
|
|
918
|
+
assert!(changed.iter().all(|m| m.body.who == vec![7]), "a change is told to its player alone");
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
#[test]
|
|
922
|
+
fn a_loot_roll_pays_its_cost_and_pity_guarantees_the_rare_within_ten() {
|
|
923
|
+
let mut h = hub();
|
|
924
|
+
let _: bool =
|
|
925
|
+
h.call_typed(Caller::Developer(1), "deposit", &json!({ "ref": "gems", "bundle": { "currency": { "gems": 200 } } })).unwrap();
|
|
926
|
+
let mut since = 0;
|
|
927
|
+
for i in 0..12 {
|
|
928
|
+
let o: Opened = h.call_typed(Caller::Player(7), "open", &json!({ "table": "chest", "action_id": format!("o{i}") })).unwrap();
|
|
929
|
+
since = if o.roll.item == "relic" { 0 } else { since + 1 };
|
|
930
|
+
assert!(since < 10);
|
|
931
|
+
}
|
|
932
|
+
let w: WalletView = h.call_typed(Caller::Player(7), "wallet", &()).unwrap();
|
|
933
|
+
assert_eq!(w.balances["gems"], 220 - 120);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
#[test]
|
|
937
|
+
fn production_catches_up_and_collect_fills_the_inventory() {
|
|
938
|
+
let mut h = hub();
|
|
939
|
+
h.advance(30 * 60_000).unwrap();
|
|
940
|
+
let got: Bundle = h.call_typed(Caller::Player(7), "collect", &json!({ "action_id": "k1" })).unwrap();
|
|
941
|
+
assert_eq!((got.items["wood"], got.items["ore"]), (60, 30), "two and one a minute for half an hour");
|
|
942
|
+
let p: ProgressView = h.call_typed(Caller::Player(7), "progress", &()).unwrap();
|
|
943
|
+
assert_eq!(p.achievements.iter().map(|a| a.count).sum::<u64>(), 0);
|
|
944
|
+
let inv: InventoryView = h.call_typed(Caller::Player(7), "inventory", &()).unwrap();
|
|
945
|
+
assert_eq!(inv.stacks.iter().map(|s| s.count).sum::<u64>(), 90);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
#[test]
|
|
949
|
+
fn a_hold_settles_or_releases_and_each_reference_runs_once() {
|
|
950
|
+
let mut h = hub();
|
|
951
|
+
let call = |h: &mut TestHub<PlayerHub>, method: &str, args: serde_json::Value| {
|
|
952
|
+
h.call(Caller::Instance(3), method, &encode(&args).unwrap())
|
|
953
|
+
};
|
|
954
|
+
call(&mut h, "hold", json!({ "ref": "m:o:1", "bundle": { "currency": { "gold": 60 } } })).unwrap();
|
|
955
|
+
call(&mut h, "hold", json!({ "ref": "m:o:1", "bundle": { "currency": { "gold": 60 } } })).unwrap();
|
|
956
|
+
let w: WalletView = h.call_typed(Caller::Player(7), "wallet", &()).unwrap();
|
|
957
|
+
assert_eq!((w.balances["gold"], w.held["gold"]), (40, 60), "held once");
|
|
958
|
+
call(&mut h, "settle", json!({ "ref": "m:f:1", "hold": "m:o:1", "bundle": { "currency": { "gold": 25 } } })).unwrap();
|
|
959
|
+
call(&mut h, "settle", json!({ "ref": "m:f:1", "hold": "m:o:1", "bundle": { "currency": { "gold": 25 } } })).unwrap();
|
|
960
|
+
let back: Bundle = decode(&call(&mut h, "release", json!({ "ref": "m:o:1:r", "hold": "m:o:1" })).unwrap()).unwrap();
|
|
961
|
+
assert_eq!(back, Bundle::coins("gold", 35), "settled once, the rest released");
|
|
962
|
+
let e = call(&mut h, "hold", json!({ "ref": "m:o:2", "bundle": { "items": { "relic": 1 } } })).unwrap_err();
|
|
963
|
+
assert_eq!(code(&e), Some(codes::FORBIDDEN), "a relic does not change hands");
|
|
964
|
+
let e = h
|
|
965
|
+
.call(Caller::Player(7), "deposit", &encode(&json!({ "ref": "x", "bundle": { "currency": { "gold": 1 } } })).unwrap())
|
|
966
|
+
.unwrap_err();
|
|
967
|
+
assert_eq!(code(&e), Some(codes::FORBIDDEN), "a player does not give themselves gold");
|
|
968
|
+
call(&mut h, "exchange", json!({ "ref": "m:b:1", "pay": { "currency": { "gold": 50 } }, "get": { "items": { "sword": 1 } } }))
|
|
969
|
+
.unwrap();
|
|
970
|
+
let e =
|
|
971
|
+
call(&mut h, "exchange", json!({ "ref": "m:b:2", "pay": { "currency": { "gold": 50 } }, "get": { "items": { "sword": 1 } } }))
|
|
972
|
+
.unwrap_err();
|
|
973
|
+
assert_eq!(code(&e), Some(codes::INSUFFICIENT));
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
#[test]
|
|
977
|
+
fn loot_is_seeded_with_what_a_client_cannot_see() {
|
|
978
|
+
let draws = |entropy: u64, secret: Option<&str>| {
|
|
979
|
+
let config = Config { secret: secret.map(String::from), ..Config::default() };
|
|
980
|
+
let mut hub = PlayerHub::new(config, 7, 1, entropy).unwrap();
|
|
981
|
+
(0..8).map(|_| hub.rng.next_u64()).collect::<Vec<_>>()
|
|
982
|
+
};
|
|
983
|
+
assert_ne!(draws(1, None), draws(2, None), "another instance or start rolls otherwise");
|
|
984
|
+
assert_ne!(draws(1, None), draws(1, Some("s3cret")), "and so does the config's secret");
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
#[test]
|
|
988
|
+
fn a_snapshot_from_before_loads_and_another_key_is_refused() {
|
|
989
|
+
let bytes = encode(hub().hub()).unwrap();
|
|
990
|
+
let back: PlayerHub = decode(&bytes).unwrap();
|
|
991
|
+
assert_eq!(back.owner, 7);
|
|
992
|
+
assert!(TestHub::<PlayerHub>::spawn("player", "not-a-player").is_err());
|
|
993
|
+
}
|
|
994
|
+
}
|