@crowdedkingdoms/ckx-kit 0.2.0 → 0.4.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 +13 -1
- 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/mod.rs +542 -0
- 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 +137 -0
- package/crates/ckx-sdk/src/kit/shard.rs +65 -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/kit/wire.rs +266 -0
- package/crates/ckx-sdk/src/lib.rs +13 -0
- package/crates/ckx-sdk/src/model.rs +5 -0
- package/crates/crowdy-client-sdk/Cargo.toml +1 -1
- package/dist/cli/src/new.js +9 -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 +12 -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/matchmaker/Cargo.toml +1 -1
- package/starters/matchmaker/src/lib.rs +334 -74
- 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/npc-mobs/src/lib.rs +277 -67
- package/starters/racing/Cargo.toml +14 -0
- package/starters/racing/src/lib.rs +1151 -0
- package/starters/session/Cargo.toml +1 -1
- package/starters/session/src/lib.rs +191 -86
- 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
- package/starters/world-tick/src/lib.rs +151 -38
|
@@ -7,12 +7,24 @@
|
|
|
7
7
|
//! Endpoints: `state` (anyone), `harvest { node }` (players), and for the app's developers
|
|
8
8
|
//! `schedule { name, start_ms, end_ms, modifiers }` and `cancel { name }`. The spawn seed, if
|
|
9
9
|
//! any, is a JSON [`Config`].
|
|
10
|
+
//!
|
|
11
|
+
//! It speaks the kit protocol (`ckx_sdk::kit`). The `world` topic carries the [`Conditions`]
|
|
12
|
+
//! as `snapshot` messages; the `nodes` topic carries each resource node's changes as `delta`s
|
|
13
|
+
//! (`id`, `type_name` `Node`, `props` `amount` and `regrow_at`). `snapshot { topic }` answers
|
|
14
|
+
//! either. Changes may carry an `action_id`; refusals carry `kit:<code>:` codes. It is the root
|
|
15
|
+
//! hub, which takes 50 calls a second for the whole app: clients subscribe to its topics
|
|
16
|
+
//! rather than polling `state`.
|
|
10
17
|
|
|
11
18
|
use std::collections::BTreeMap;
|
|
12
19
|
|
|
20
|
+
use ckx_sdk::kit::{self, Containers, Publisher, Receipts, codes};
|
|
21
|
+
use ckx_sdk::model::Published;
|
|
13
22
|
use ckx_sdk::prelude::*;
|
|
23
|
+
use serde_json::json;
|
|
14
24
|
|
|
15
25
|
pub const TICK_MS: u64 = 1_000;
|
|
26
|
+
pub const WORLD_TOPIC: &str = "world";
|
|
27
|
+
pub const NODES_TOPIC: &str = "nodes";
|
|
16
28
|
/// The longest step the clock takes at once, so a world that was stopped resumes where it was.
|
|
17
29
|
const MAX_STEP_MS: u64 = 5_000;
|
|
18
30
|
const HARVEST_COOLDOWN_MS: u64 = 500;
|
|
@@ -131,8 +143,8 @@ impl World {
|
|
|
131
143
|
x
|
|
132
144
|
}
|
|
133
145
|
|
|
134
|
-
/// Advances the world to `now` (Unix milliseconds).
|
|
135
|
-
pub fn tick(&mut self, now: u64) {
|
|
146
|
+
/// Advances the world to `now` (Unix milliseconds), and returns the nodes that grew back.
|
|
147
|
+
pub fn tick(&mut self, now: u64) -> Vec<u32> {
|
|
136
148
|
self.ticks += 1;
|
|
137
149
|
self.clock_ms += now.saturating_sub(self.last_tick).min(MAX_STEP_MS);
|
|
138
150
|
self.last_tick = now;
|
|
@@ -147,14 +159,17 @@ impl World {
|
|
|
147
159
|
self.weather_until = self.clock_ms + hour * (2 + self.next_rand() % 5);
|
|
148
160
|
}
|
|
149
161
|
let amount = self.config.node_amount;
|
|
162
|
+
let mut regrown = Vec::new();
|
|
150
163
|
for n in &mut self.nodes {
|
|
151
164
|
if n.regrow_at.is_some_and(|at| now >= at) {
|
|
152
165
|
n.amount = amount;
|
|
153
166
|
n.regrow_at = None;
|
|
167
|
+
regrown.push(n.id);
|
|
154
168
|
}
|
|
155
169
|
}
|
|
156
170
|
self.harvested.retain(|_, at| now.saturating_sub(*at) < 60_000);
|
|
157
171
|
self.events.retain(|_, e| e.end_ms > now);
|
|
172
|
+
regrown
|
|
158
173
|
}
|
|
159
174
|
|
|
160
175
|
pub fn conditions(&self, now: u64) -> Conditions {
|
|
@@ -171,12 +186,13 @@ impl World {
|
|
|
171
186
|
|
|
172
187
|
pub fn harvest(&mut self, player: u64, node: u32, now: u64) -> Result<u32> {
|
|
173
188
|
if self.harvested.get(&player).is_some_and(|at| now < at + HARVEST_COOLDOWN_MS) {
|
|
174
|
-
return Err(
|
|
189
|
+
return Err(kit::error(codes::COOLDOWN, "harvesting too fast"));
|
|
175
190
|
}
|
|
176
191
|
let regrow_at = now + self.config.regrow_ms;
|
|
177
|
-
let n =
|
|
192
|
+
let n =
|
|
193
|
+
self.nodes.iter_mut().find(|n| n.id == node).ok_or_else(|| kit::error(codes::NOT_FOUND, format!("no resource node {node}")))?;
|
|
178
194
|
if n.amount == 0 {
|
|
179
|
-
return Err(
|
|
195
|
+
return Err(kit::error(codes::EMPTY, "that node is empty; it grows back"));
|
|
180
196
|
}
|
|
181
197
|
n.amount -= 1;
|
|
182
198
|
if n.amount == 0 {
|
|
@@ -188,21 +204,32 @@ impl World {
|
|
|
188
204
|
|
|
189
205
|
pub fn schedule(&mut self, event: Event, now: u64) -> Result<()> {
|
|
190
206
|
if event.name.is_empty() || event.name.len() > 64 {
|
|
191
|
-
return Err(
|
|
207
|
+
return Err(kit::error(codes::BAD_REQUEST, "an event name is 1-64 characters"));
|
|
192
208
|
}
|
|
193
209
|
if event.end_ms <= event.start_ms || event.end_ms <= now {
|
|
194
|
-
return Err(
|
|
210
|
+
return Err(kit::error(codes::BAD_REQUEST, "an event ends after it starts, and in the future"));
|
|
195
211
|
}
|
|
196
212
|
if event.modifiers.len() > MAX_MODIFIERS || event.modifiers.values().any(|v| !v.is_finite() || *v <= 0.0 || *v > 100.0) {
|
|
197
|
-
return Err(
|
|
213
|
+
return Err(kit::error(codes::BAD_REQUEST, format!("at most {MAX_MODIFIERS} modifiers, each above 0 and at most 100")));
|
|
198
214
|
}
|
|
199
215
|
if !self.events.contains_key(&event.name) && self.events.len() >= MAX_EVENTS {
|
|
200
|
-
return Err(
|
|
216
|
+
return Err(kit::error(codes::LIMIT, format!("at most {MAX_EVENTS} events are scheduled at once")));
|
|
201
217
|
}
|
|
202
218
|
self.events.insert(event.name.clone(), event);
|
|
203
219
|
Ok(())
|
|
204
220
|
}
|
|
205
221
|
|
|
222
|
+
/// A resource node as the `nodes` topic shows it.
|
|
223
|
+
pub fn node_delta(&self, id: u32) -> Option<Published> {
|
|
224
|
+
let n = self.nodes.iter().find(|n| n.id == id)?;
|
|
225
|
+
let props = BTreeMap::from([("amount".to_owned(), json!(n.amount)), ("regrow_at".to_owned(), json!(n.regrow_at))]);
|
|
226
|
+
Some(Published { id: u64::from(n.id), type_name: Some("Node".to_owned()), props: Some(props), removed: false })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
pub fn node_collection(&self) -> Containers {
|
|
230
|
+
Containers { containers: self.nodes.iter().filter_map(|n| self.node_delta(n.id)).collect() }
|
|
231
|
+
}
|
|
232
|
+
|
|
206
233
|
pub fn view(&self, now: u64) -> View {
|
|
207
234
|
View {
|
|
208
235
|
conditions: self.conditions(now),
|
|
@@ -214,7 +241,66 @@ impl World {
|
|
|
214
241
|
}
|
|
215
242
|
}
|
|
216
243
|
|
|
217
|
-
|
|
244
|
+
/// The hub: the world, and what the kit protocol keeps beside it.
|
|
245
|
+
#[derive(Serialize, Deserialize, Default)]
|
|
246
|
+
pub struct WorldHub {
|
|
247
|
+
#[serde(flatten)]
|
|
248
|
+
pub world: World,
|
|
249
|
+
#[serde(default)]
|
|
250
|
+
pub kit: Publisher,
|
|
251
|
+
#[serde(default)]
|
|
252
|
+
pub receipts: Receipts,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
fn developer(call: &Call<'_>) -> Result<u64> {
|
|
256
|
+
call.developer().map_err(|_| kit::error(codes::FORBIDDEN, format!("`{}` is for the app's developers", call.method)))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
impl WorldHub {
|
|
260
|
+
fn act(&mut self, ctx: &Ctx, call: &Call<'_>, now: u64) -> Result<Vec<u8>> {
|
|
261
|
+
match call.method {
|
|
262
|
+
"harvest" => {
|
|
263
|
+
let player = call.player()?;
|
|
264
|
+
let req: Harvest = kit::args(call)?;
|
|
265
|
+
let left = self.world.harvest(player, req.node, now)?;
|
|
266
|
+
if let Some(d) = self.world.node_delta(req.node) {
|
|
267
|
+
self.kit.delta(ctx, NODES_TOPIC, &d)?;
|
|
268
|
+
}
|
|
269
|
+
encode(&left)
|
|
270
|
+
}
|
|
271
|
+
"schedule" => {
|
|
272
|
+
developer(call)?;
|
|
273
|
+
self.world.schedule(kit::args(call)?, now)?;
|
|
274
|
+
ctx.persist_now();
|
|
275
|
+
self.publish_conditions(ctx, now, true)?;
|
|
276
|
+
encode(&true)
|
|
277
|
+
}
|
|
278
|
+
"cancel" => {
|
|
279
|
+
developer(call)?;
|
|
280
|
+
let req: Cancel = kit::args(call)?;
|
|
281
|
+
let removed = self.world.events.remove(&req.name).is_some();
|
|
282
|
+
self.publish_conditions(ctx, now, true)?;
|
|
283
|
+
encode(&removed)
|
|
284
|
+
}
|
|
285
|
+
other => Err(Error::unknown_method(other)),
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/// Publishes the conditions when they changed in a way players notice, or `always`, and
|
|
290
|
+
/// every [`PUBLISH_EVERY`] ticks regardless so clients keep the clock.
|
|
291
|
+
fn publish_conditions(&mut self, ctx: &Ctx, now: u64, always: bool) -> Result<()> {
|
|
292
|
+
let c = self.world.conditions(now);
|
|
293
|
+
let notable =
|
|
294
|
+
self.world.published.as_ref().is_none_or(|p| p.night != c.night || p.weather != c.weather || p.modifiers != c.modifiers);
|
|
295
|
+
if always || notable || self.world.ticks.is_multiple_of(PUBLISH_EVERY) {
|
|
296
|
+
self.kit.snapshot(ctx, WORLD_TOPIC, &c)?;
|
|
297
|
+
self.world.published = Some(c);
|
|
298
|
+
}
|
|
299
|
+
Ok(())
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
impl Hub for WorldHub {
|
|
218
304
|
fn spawn(ctx: &Ctx, seed: &[u8]) -> Result<Self> {
|
|
219
305
|
let config = if seed.is_empty() {
|
|
220
306
|
Config::default()
|
|
@@ -222,9 +308,10 @@ impl Hub for World {
|
|
|
222
308
|
serde_json::from_slice(seed).map_err(|e| Error::new(format!("seed is not a JSON config: {e}")))?
|
|
223
309
|
};
|
|
224
310
|
ctx.timer_every("tick", TICK_MS)?;
|
|
225
|
-
Ok(Self::new(config, ctx.now_ms()))
|
|
311
|
+
Ok(Self { world: World::new(config, ctx.now_ms()), ..Default::default() })
|
|
226
312
|
}
|
|
227
313
|
|
|
314
|
+
/// Reads this version's snapshots and the earlier ones, which held the world alone.
|
|
228
315
|
fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
|
|
229
316
|
decode(snapshot)
|
|
230
317
|
}
|
|
@@ -236,24 +323,13 @@ impl Hub for World {
|
|
|
236
323
|
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
237
324
|
let now = ctx.now_ms();
|
|
238
325
|
match call.method {
|
|
239
|
-
"state" => encode(&self.view(now)),
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
call.developer()?;
|
|
247
|
-
self.schedule(call.decode()?, now)?;
|
|
248
|
-
ctx.persist_now();
|
|
249
|
-
encode(&true)
|
|
250
|
-
}
|
|
251
|
-
"cancel" => {
|
|
252
|
-
call.developer()?;
|
|
253
|
-
let req: Cancel = call.decode()?;
|
|
254
|
-
encode(&self.events.remove(&req.name).is_some())
|
|
255
|
-
}
|
|
256
|
-
other => Err(Error::unknown_method(other)),
|
|
326
|
+
"state" => encode(&self.world.view(now)),
|
|
327
|
+
kit::SNAPSHOT_METHOD => match kit::snapshot_topic(&call, WORLD_TOPIC).as_str() {
|
|
328
|
+
WORLD_TOPIC => self.kit.snapshot_reply(ctx, WORLD_TOPIC, &self.world.conditions(now)),
|
|
329
|
+
NODES_TOPIC => self.kit.snapshot_reply(ctx, NODES_TOPIC, &self.world.node_collection()),
|
|
330
|
+
other => Err(kit::error(codes::NOT_FOUND, format!("no topic `{other}`"))),
|
|
331
|
+
},
|
|
332
|
+
_ => Receipts::run(self, |h| &mut h.receipts, &call, now, |h| h.act(ctx, &call, now)),
|
|
257
333
|
}
|
|
258
334
|
}
|
|
259
335
|
|
|
@@ -262,23 +338,21 @@ impl Hub for World {
|
|
|
262
338
|
return Ok(());
|
|
263
339
|
}
|
|
264
340
|
let now = ctx.now_ms();
|
|
265
|
-
self.tick(now)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
ctx.publish("world", &encode(&c)?);
|
|
270
|
-
self.published = Some(c);
|
|
341
|
+
for id in self.world.tick(now) {
|
|
342
|
+
if let Some(d) = self.world.node_delta(id) {
|
|
343
|
+
self.kit.delta(ctx, NODES_TOPIC, &d)?;
|
|
344
|
+
}
|
|
271
345
|
}
|
|
272
|
-
|
|
346
|
+
self.publish_conditions(ctx, now, false)
|
|
273
347
|
}
|
|
274
348
|
|
|
275
349
|
fn on_presence(&mut self, _ctx: &Ctx, presence: &Presence) -> Result<()> {
|
|
276
|
-
self.online = presence.count;
|
|
350
|
+
self.world.online = presence.count;
|
|
277
351
|
Ok(())
|
|
278
352
|
}
|
|
279
353
|
}
|
|
280
354
|
|
|
281
|
-
ckx_sdk::export_hub!(
|
|
355
|
+
ckx_sdk::export_hub!(WorldHub);
|
|
282
356
|
|
|
283
357
|
#[cfg(test)]
|
|
284
358
|
mod tests {
|
|
@@ -343,4 +417,43 @@ mod tests {
|
|
|
343
417
|
let c: Config = serde_json::from_slice(br#"{"nodes": 3}"#).unwrap();
|
|
344
418
|
assert_eq!((c.nodes, c.node_amount), (3, Config::default().node_amount));
|
|
345
419
|
}
|
|
420
|
+
|
|
421
|
+
#[derive(Serialize)]
|
|
422
|
+
struct H<'a> {
|
|
423
|
+
node: u32,
|
|
424
|
+
action_id: &'a str,
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
#[test]
|
|
428
|
+
fn harvests_and_regrowth_are_deltas_on_the_nodes_topic_and_a_resent_harvest_takes_once() {
|
|
429
|
+
use ckx_sdk::kit::{Message, MessageKind};
|
|
430
|
+
use ckx_sdk::testing::TestHub;
|
|
431
|
+
let seed = br#"{"nodes": 2, "node_amount": 1, "regrow_ms": 1000}"#;
|
|
432
|
+
let mut hub = TestHub::<WorldHub>::spawn_with("world", "", seed).unwrap();
|
|
433
|
+
let left: u32 = hub.call_typed(Caller::Player(1), "harvest", &H { node: 0, action_id: "h1" }).unwrap();
|
|
434
|
+
let again: u32 = hub.call_typed(Caller::Player(1), "harvest", &H { node: 0, action_id: "h1" }).unwrap();
|
|
435
|
+
assert_eq!((left, again), (0, 0));
|
|
436
|
+
let e = hub.call(Caller::Player(1), "harvest", &encode(&H { node: 0, action_id: "h2" }).unwrap()).unwrap_err();
|
|
437
|
+
assert_eq!(kit::error_code(&e.message).map(|c| c.0), Some(codes::COOLDOWN));
|
|
438
|
+
hub.advance(1_500).unwrap();
|
|
439
|
+
let deltas: Vec<Message<Published>> =
|
|
440
|
+
hub.published().iter().filter(|(t, _)| t == NODES_TOPIC).map(|(_, b)| decode(b).unwrap()).collect();
|
|
441
|
+
assert_eq!(deltas.len(), 2, "emptied, then grown back");
|
|
442
|
+
assert!(deltas.iter().all(|m| m.header.kind == MessageKind::Delta && m.body.id == 0));
|
|
443
|
+
assert_eq!(deltas[1].body.props.as_ref().unwrap()["amount"], json!(1));
|
|
444
|
+
let conditions: Vec<Message<Conditions>> =
|
|
445
|
+
hub.published().iter().filter(|(t, _)| t == WORLD_TOPIC).map(|(_, b)| decode(b).unwrap()).collect();
|
|
446
|
+
assert!(!conditions.is_empty(), "the first tick publishes the conditions");
|
|
447
|
+
let nodes: Message<Containers> = hub.call_typed(Caller::Player(2), "snapshot", &json!({ "topic": NODES_TOPIC })).unwrap();
|
|
448
|
+
assert_eq!((nodes.body.containers.len(), nodes.header.seq), (2, 2));
|
|
449
|
+
assert!(hub.call(Caller::Player(1), "schedule", &[]).is_err(), "developers only");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#[test]
|
|
453
|
+
fn a_snapshot_from_before_the_kit_protocol_loads() {
|
|
454
|
+
use ckx_sdk::testing::TestHub;
|
|
455
|
+
let old = encode(&world()).unwrap();
|
|
456
|
+
let hub = TestHub::<WorldHub>::load("world", "", &old, 1).unwrap();
|
|
457
|
+
assert_eq!(hub.hub().world.nodes.len(), 2);
|
|
458
|
+
}
|
|
346
459
|
}
|