@crowdedkingdoms/ckx-kit 0.0.0-stage → 0.1.1-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/LICENSE-APACHE +176 -0
- package/LICENSE-MIT +21 -0
- package/README.md +76 -2
- package/checks/compute-toolchain/host-catalog.json +68 -0
- package/crates/ckx-sdk/Cargo.toml +17 -0
- package/crates/ckx-sdk/LICENSE-APACHE +176 -0
- package/crates/ckx-sdk/LICENSE-MIT +21 -0
- package/crates/ckx-sdk/README.md +47 -0
- package/crates/ckx-sdk/src/abi.rs +115 -0
- package/crates/ckx-sdk/src/cron.rs +717 -0
- package/crates/ckx-sdk/src/emit.rs +140 -0
- package/crates/ckx-sdk/src/lib.rs +588 -0
- package/crates/ckx-sdk/src/model.rs +482 -0
- package/crates/ckx-sdk/src/node.rs +942 -0
- package/crates/ckx-sdk/src/rt.rs +519 -0
- package/crates/crowdy-client-sdk/Cargo.toml +15 -0
- package/crates/crowdy-client-sdk/LICENSE-APACHE +176 -0
- package/crates/crowdy-client-sdk/LICENSE-MIT +21 -0
- package/crates/crowdy-client-sdk/README.md +25 -0
- package/crates/crowdy-client-sdk/src/lib.rs +698 -0
- package/dist/checks/src/build/client-capabilities.js +123 -0
- package/dist/checks/src/build/exec-source.js +343 -0
- package/dist/checks/src/build/rust-source-guard.js +165 -0
- package/dist/checks/src/build/wasm-inspect.js +249 -0
- package/dist/cli/src/build.js +99 -0
- package/dist/cli/src/crate.js +64 -0
- package/dist/cli/src/doctor.js +51 -0
- package/dist/cli/src/kit.js +66 -0
- package/dist/cli/src/main.js +109 -0
- package/dist/cli/src/new.js +74 -0
- package/dist/cli/src/tools.js +104 -0
- package/kit.pin +9 -0
- package/package.json +81 -4
- package/rust-toolchain.toml +6 -0
- package/starters/ckx.json +9 -0
- package/starters/grid-mod/Cargo.toml +13 -0
- package/starters/grid-mod/src/lib.rs +82 -0
- package/starters/matchmaker/Cargo.toml +13 -0
- package/starters/matchmaker/src/lib.rs +362 -0
- package/starters/npc-mobs/Cargo.toml +14 -0
- package/starters/npc-mobs/src/lib.rs +469 -0
- package/starters/session/Cargo.toml +13 -0
- package/starters/session/src/lib.rs +395 -0
- package/starters/world-tick/Cargo.toml +14 -0
- package/starters/world-tick/src/lib.rs +346 -0
- package/templates/client/Cargo.toml +16 -0
- package/templates/client/src/lib.rs +31 -0
- package/templates/hub/Cargo.toml +11 -0
- package/templates/hub/src/lib.rs +73 -0
- package/templates/spoke/Cargo.toml +11 -0
- package/templates/spoke/src/lib.rs +53 -0
- package/tools/instrument/Cargo.lock +140 -0
- package/tools/instrument/Cargo.toml +11 -0
- package/tools/instrument/src/main.rs +165 -0
- package/vendor.lock +167 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
//! The world's clock, weather and resource nodes, advanced on a timer while the app runs, and
|
|
2
|
+
//! the liveops events its developers schedule. It is the root hub, so it hears players enter
|
|
3
|
+
//! and leave the app, and other hubs watch its `world` topic: the NPC engine's mobs grow bolder
|
|
4
|
+
//! at night and under an event's `aggro` modifier. Replaces the `world-engine` and
|
|
5
|
+
//! `liveops-scheduler` templates.
|
|
6
|
+
//!
|
|
7
|
+
//! Endpoints: `state` (anyone), `harvest { node }` (players), and for the app's developers
|
|
8
|
+
//! `schedule { name, start_ms, end_ms, modifiers }` and `cancel { name }`. The spawn seed, if
|
|
9
|
+
//! any, is a JSON [`Config`].
|
|
10
|
+
|
|
11
|
+
use std::collections::BTreeMap;
|
|
12
|
+
|
|
13
|
+
use ckx_sdk::prelude::*;
|
|
14
|
+
|
|
15
|
+
pub const TICK_MS: u64 = 1_000;
|
|
16
|
+
/// The longest step the clock takes at once, so a world that was stopped resumes where it was.
|
|
17
|
+
const MAX_STEP_MS: u64 = 5_000;
|
|
18
|
+
const HARVEST_COOLDOWN_MS: u64 = 500;
|
|
19
|
+
/// Ticks between `world` pushes when nothing notable changed, so clients keep the clock.
|
|
20
|
+
const PUBLISH_EVERY: u64 = 30;
|
|
21
|
+
const MAX_EVENTS: usize = 64;
|
|
22
|
+
const MAX_MODIFIERS: usize = 16;
|
|
23
|
+
|
|
24
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
25
|
+
#[serde(default)]
|
|
26
|
+
pub struct Config {
|
|
27
|
+
/// Real milliseconds in one game day.
|
|
28
|
+
pub day_ms: u64,
|
|
29
|
+
pub nodes: u32,
|
|
30
|
+
/// What a full resource node holds.
|
|
31
|
+
pub node_amount: u32,
|
|
32
|
+
/// How long an emptied node takes to grow back.
|
|
33
|
+
pub regrow_ms: u64,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
impl Default for Config {
|
|
37
|
+
fn default() -> Self {
|
|
38
|
+
Self { day_ms: 20 * 60 * 1000, nodes: 12, node_amount: 5, regrow_ms: 60_000 }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
43
|
+
#[serde(rename_all = "snake_case")]
|
|
44
|
+
pub enum Weather {
|
|
45
|
+
#[default]
|
|
46
|
+
Clear,
|
|
47
|
+
Rain,
|
|
48
|
+
Storm,
|
|
49
|
+
Fog,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
53
|
+
pub struct Node {
|
|
54
|
+
pub id: u32,
|
|
55
|
+
pub amount: u32,
|
|
56
|
+
/// When it grows back, in Unix milliseconds; set while it is empty.
|
|
57
|
+
pub regrow_at: Option<u64>,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// A liveops event: between `start_ms` and `end_ms` (Unix milliseconds) its modifiers apply.
|
|
61
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
62
|
+
pub struct Event {
|
|
63
|
+
pub name: String,
|
|
64
|
+
pub start_ms: u64,
|
|
65
|
+
pub end_ms: u64,
|
|
66
|
+
#[serde(default)]
|
|
67
|
+
pub modifiers: BTreeMap<String, f64>,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// What the `world` topic carries.
|
|
71
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
72
|
+
pub struct Conditions {
|
|
73
|
+
/// 0 at midnight, 0.5 at noon.
|
|
74
|
+
pub time_of_day: f64,
|
|
75
|
+
pub night: bool,
|
|
76
|
+
pub weather: Weather,
|
|
77
|
+
/// The active events' modifiers; where events overlap they multiply.
|
|
78
|
+
pub modifiers: BTreeMap<String, f64>,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
|
82
|
+
pub struct View {
|
|
83
|
+
pub conditions: Conditions,
|
|
84
|
+
pub clock_ms: u64,
|
|
85
|
+
pub nodes: Vec<Node>,
|
|
86
|
+
pub events: Vec<Event>,
|
|
87
|
+
pub online: u32,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[derive(Deserialize)]
|
|
91
|
+
struct Harvest {
|
|
92
|
+
node: u32,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#[derive(Deserialize)]
|
|
96
|
+
struct Cancel {
|
|
97
|
+
name: String,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#[derive(Serialize, Deserialize, Default)]
|
|
101
|
+
pub struct World {
|
|
102
|
+
pub config: Config,
|
|
103
|
+
/// Game time since the world began, advanced only while it runs.
|
|
104
|
+
pub clock_ms: u64,
|
|
105
|
+
pub last_tick: u64,
|
|
106
|
+
pub ticks: u64,
|
|
107
|
+
pub weather: Weather,
|
|
108
|
+
/// The game time the weather next changes.
|
|
109
|
+
pub weather_until: u64,
|
|
110
|
+
pub rng: u64,
|
|
111
|
+
pub nodes: Vec<Node>,
|
|
112
|
+
pub events: BTreeMap<String, Event>,
|
|
113
|
+
pub online: u32,
|
|
114
|
+
/// Each player's last harvest, for the cooldown.
|
|
115
|
+
pub harvested: BTreeMap<u64, u64>,
|
|
116
|
+
pub published: Option<Conditions>,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
impl World {
|
|
120
|
+
pub fn new(config: Config, now: u64) -> Self {
|
|
121
|
+
let nodes = (0..config.nodes).map(|id| Node { id, amount: config.node_amount, regrow_at: None }).collect();
|
|
122
|
+
Self { rng: now | 1, last_tick: now, nodes, config, ..Default::default() }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fn next_rand(&mut self) -> u64 {
|
|
126
|
+
let mut x = self.rng;
|
|
127
|
+
x ^= x << 13;
|
|
128
|
+
x ^= x >> 7;
|
|
129
|
+
x ^= x << 17;
|
|
130
|
+
self.rng = x;
|
|
131
|
+
x
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Advances the world to `now` (Unix milliseconds).
|
|
135
|
+
pub fn tick(&mut self, now: u64) {
|
|
136
|
+
self.ticks += 1;
|
|
137
|
+
self.clock_ms += now.saturating_sub(self.last_tick).min(MAX_STEP_MS);
|
|
138
|
+
self.last_tick = now;
|
|
139
|
+
if self.clock_ms >= self.weather_until {
|
|
140
|
+
self.weather = match self.next_rand() % 10 {
|
|
141
|
+
0..=5 => Weather::Clear,
|
|
142
|
+
6 | 7 => Weather::Rain,
|
|
143
|
+
8 => Weather::Storm,
|
|
144
|
+
_ => Weather::Fog,
|
|
145
|
+
};
|
|
146
|
+
let hour = (self.config.day_ms / 24).max(1);
|
|
147
|
+
self.weather_until = self.clock_ms + hour * (2 + self.next_rand() % 5);
|
|
148
|
+
}
|
|
149
|
+
let amount = self.config.node_amount;
|
|
150
|
+
for n in &mut self.nodes {
|
|
151
|
+
if n.regrow_at.is_some_and(|at| now >= at) {
|
|
152
|
+
n.amount = amount;
|
|
153
|
+
n.regrow_at = None;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
self.harvested.retain(|_, at| now.saturating_sub(*at) < 60_000);
|
|
157
|
+
self.events.retain(|_, e| e.end_ms > now);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
pub fn conditions(&self, now: u64) -> Conditions {
|
|
161
|
+
let day = self.config.day_ms.max(1);
|
|
162
|
+
let time_of_day = (self.clock_ms % day) as f64 / day as f64;
|
|
163
|
+
let mut modifiers = BTreeMap::new();
|
|
164
|
+
for e in self.events.values().filter(|e| e.start_ms <= now && now < e.end_ms) {
|
|
165
|
+
for (k, v) in &e.modifiers {
|
|
166
|
+
*modifiers.entry(k.clone()).or_insert(1.0) *= v;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
Conditions { time_of_day, night: !(0.25..0.75).contains(&time_of_day), weather: self.weather, modifiers }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
pub fn harvest(&mut self, player: u64, node: u32, now: u64) -> Result<u32> {
|
|
173
|
+
if self.harvested.get(&player).is_some_and(|at| now < at + HARVEST_COOLDOWN_MS) {
|
|
174
|
+
return Err(Error::new("harvesting too fast"));
|
|
175
|
+
}
|
|
176
|
+
let regrow_at = now + self.config.regrow_ms;
|
|
177
|
+
let n = self.nodes.iter_mut().find(|n| n.id == node).ok_or_else(|| Error::new(format!("no resource node {node}")))?;
|
|
178
|
+
if n.amount == 0 {
|
|
179
|
+
return Err(Error::new("that node is empty; it grows back"));
|
|
180
|
+
}
|
|
181
|
+
n.amount -= 1;
|
|
182
|
+
if n.amount == 0 {
|
|
183
|
+
n.regrow_at = Some(regrow_at);
|
|
184
|
+
}
|
|
185
|
+
self.harvested.insert(player, now);
|
|
186
|
+
Ok(n.amount)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
pub fn schedule(&mut self, event: Event, now: u64) -> Result<()> {
|
|
190
|
+
if event.name.is_empty() || event.name.len() > 64 {
|
|
191
|
+
return Err(Error::new("an event name is 1-64 characters"));
|
|
192
|
+
}
|
|
193
|
+
if event.end_ms <= event.start_ms || event.end_ms <= now {
|
|
194
|
+
return Err(Error::new("an event ends after it starts, and in the future"));
|
|
195
|
+
}
|
|
196
|
+
if event.modifiers.len() > MAX_MODIFIERS || event.modifiers.values().any(|v| !v.is_finite() || *v <= 0.0 || *v > 100.0) {
|
|
197
|
+
return Err(Error::new(format!("at most {MAX_MODIFIERS} modifiers, each above 0 and at most 100")));
|
|
198
|
+
}
|
|
199
|
+
if !self.events.contains_key(&event.name) && self.events.len() >= MAX_EVENTS {
|
|
200
|
+
return Err(Error::new(format!("at most {MAX_EVENTS} events are scheduled at once")));
|
|
201
|
+
}
|
|
202
|
+
self.events.insert(event.name.clone(), event);
|
|
203
|
+
Ok(())
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
pub fn view(&self, now: u64) -> View {
|
|
207
|
+
View {
|
|
208
|
+
conditions: self.conditions(now),
|
|
209
|
+
clock_ms: self.clock_ms,
|
|
210
|
+
nodes: self.nodes.clone(),
|
|
211
|
+
events: self.events.values().cloned().collect(),
|
|
212
|
+
online: self.online,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
impl Hub for World {
|
|
218
|
+
fn spawn(ctx: &Ctx, seed: &[u8]) -> Result<Self> {
|
|
219
|
+
let config = if seed.is_empty() {
|
|
220
|
+
Config::default()
|
|
221
|
+
} else {
|
|
222
|
+
serde_json::from_slice(seed).map_err(|e| Error::new(format!("seed is not a JSON config: {e}")))?
|
|
223
|
+
};
|
|
224
|
+
ctx.timer_every("tick", TICK_MS)?;
|
|
225
|
+
Ok(Self::new(config, ctx.now_ms()))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
|
|
229
|
+
decode(snapshot)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
|
|
233
|
+
encode(self)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
237
|
+
let now = ctx.now_ms();
|
|
238
|
+
match call.method {
|
|
239
|
+
"state" => encode(&self.view(now)),
|
|
240
|
+
"harvest" => {
|
|
241
|
+
let player = call.player()?;
|
|
242
|
+
let req: Harvest = call.decode()?;
|
|
243
|
+
encode(&self.harvest(player, req.node, now)?)
|
|
244
|
+
}
|
|
245
|
+
"schedule" => {
|
|
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)),
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
|
|
261
|
+
if timer != "tick" {
|
|
262
|
+
return Ok(());
|
|
263
|
+
}
|
|
264
|
+
let now = ctx.now_ms();
|
|
265
|
+
self.tick(now);
|
|
266
|
+
let c = self.conditions(now);
|
|
267
|
+
let notable = self.published.as_ref().is_none_or(|p| p.night != c.night || p.weather != c.weather || p.modifiers != c.modifiers);
|
|
268
|
+
if notable || self.ticks.is_multiple_of(PUBLISH_EVERY) {
|
|
269
|
+
ctx.publish("world", &encode(&c)?);
|
|
270
|
+
self.published = Some(c);
|
|
271
|
+
}
|
|
272
|
+
Ok(())
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
fn on_presence(&mut self, _ctx: &Ctx, presence: &Presence) -> Result<()> {
|
|
276
|
+
self.online = presence.count;
|
|
277
|
+
Ok(())
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
ckx_sdk::export_hub!(World);
|
|
282
|
+
|
|
283
|
+
#[cfg(test)]
|
|
284
|
+
mod tests {
|
|
285
|
+
use super::*;
|
|
286
|
+
|
|
287
|
+
fn world() -> World {
|
|
288
|
+
World::new(Config { day_ms: 24_000, nodes: 2, node_amount: 2, regrow_ms: 1_000 }, 1_000)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#[test]
|
|
292
|
+
fn the_clock_steps_at_most_five_seconds_so_a_stopped_world_resumes() {
|
|
293
|
+
let mut w = world();
|
|
294
|
+
w.tick(2_000);
|
|
295
|
+
assert_eq!(w.clock_ms, 1_000);
|
|
296
|
+
w.tick(1_000_000);
|
|
297
|
+
assert_eq!(w.clock_ms, 1_000 + MAX_STEP_MS);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[test]
|
|
301
|
+
fn night_is_the_first_and_last_quarter_of_the_day() {
|
|
302
|
+
let mut w = world();
|
|
303
|
+
assert!(w.conditions(0).night);
|
|
304
|
+
w.clock_ms = 12_000;
|
|
305
|
+
assert!(!w.conditions(0).night);
|
|
306
|
+
w.clock_ms = 20_000;
|
|
307
|
+
assert!(w.conditions(0).night);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#[test]
|
|
311
|
+
fn a_node_empties_cools_down_and_grows_back() {
|
|
312
|
+
let mut w = world();
|
|
313
|
+
assert_eq!(w.harvest(7, 0, 10_000).unwrap(), 1);
|
|
314
|
+
assert!(w.harvest(7, 0, 10_100).is_err(), "cooldown");
|
|
315
|
+
assert_eq!(w.harvest(7, 0, 10_600).unwrap(), 0);
|
|
316
|
+
assert!(w.harvest(8, 0, 10_700).is_err(), "empty");
|
|
317
|
+
assert!(w.harvest(8, 9, 10_700).is_err(), "no such node");
|
|
318
|
+
w.tick(11_700);
|
|
319
|
+
assert_eq!(w.nodes[0].amount, 2);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#[test]
|
|
323
|
+
fn events_are_bounded_active_in_their_window_and_multiply() {
|
|
324
|
+
let mut w = world();
|
|
325
|
+
let ev = |name: &str, start, end, aggro| Event {
|
|
326
|
+
name: name.into(),
|
|
327
|
+
start_ms: start,
|
|
328
|
+
end_ms: end,
|
|
329
|
+
modifiers: BTreeMap::from([("aggro".to_string(), aggro)]),
|
|
330
|
+
};
|
|
331
|
+
w.schedule(ev("a", 100, 5_000, 2.0), 0).unwrap();
|
|
332
|
+
w.schedule(ev("b", 200, 5_000, 1.5), 0).unwrap();
|
|
333
|
+
assert!(w.schedule(ev("c", 300, 200, 1.0), 0).is_err(), "ends before it starts");
|
|
334
|
+
assert!(w.schedule(ev("d", 300, 400, 0.0), 0).is_err(), "modifier out of range");
|
|
335
|
+
assert_eq!(w.conditions(50).modifiers.get("aggro"), None);
|
|
336
|
+
assert_eq!(w.conditions(250).modifiers.get("aggro"), Some(&3.0));
|
|
337
|
+
w.tick(6_000);
|
|
338
|
+
assert!(w.events.is_empty(), "ended events are dropped");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
#[test]
|
|
342
|
+
fn a_seed_is_a_json_config_with_defaults() {
|
|
343
|
+
let c: Config = serde_json::from_slice(br#"{"nodes": 3}"#).unwrap();
|
|
344
|
+
assert_eq!((c.nodes, c.node_amount), (3, Config::default().node_amount));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "client-half"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
|
|
6
|
+
[lib]
|
|
7
|
+
crate-type = ["cdylib"]
|
|
8
|
+
|
|
9
|
+
# How often visitors' browsers call tick (clamped 16–1000 ms).
|
|
10
|
+
# 1000 = HUD/text. 50 = physics minigames (pool). 16 = shooters, if a tick stays cheap.
|
|
11
|
+
[package.metadata.crowdy]
|
|
12
|
+
tick_interval_ms = 1000
|
|
13
|
+
|
|
14
|
+
[dependencies]
|
|
15
|
+
crowdy-client-sdk = "0.1.0"
|
|
16
|
+
serde_json = "1"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
use crowdy_client_sdk as crowdy;
|
|
2
|
+
|
|
3
|
+
fn init() {
|
|
4
|
+
// Runs once in each visitor's browser, after they consent to this CLIENT half
|
|
5
|
+
// (or trust you) and while they stand in this grid.
|
|
6
|
+
crowdy::log(1, "ready");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
fn tick(_dt_ms: u32) {
|
|
10
|
+
// Every tick_interval_ms (Cargo.toml). The state blob lasts as long as the page's worker.
|
|
11
|
+
let ticks = u32::from_le_bytes(crowdy::state_get().try_into().unwrap_or([0; 4])).wrapping_add(1);
|
|
12
|
+
crowdy::state_set(&ticks.to_le_bytes());
|
|
13
|
+
// Host calls cross the page's broker as the visiting player, inside this grid: the HUD and
|
|
14
|
+
// overlay, chunk and actor reads, voxel writes, spatial and channel sends, grid events.
|
|
15
|
+
// Type "crowdy::api::" for the rest. The page's DOM and tokens are never reachable.
|
|
16
|
+
let _ = crowdy::api::hud_set(serde_json::json!({ "text": format!("Ticks here: {ticks}") }));
|
|
17
|
+
//
|
|
18
|
+
// Mouse (holodeck canvas only; Studio chrome is omitted). Drain every tick:
|
|
19
|
+
// let data = crowdy::api::pointer_clicks().unwrap_or(serde_json::json!({}));
|
|
20
|
+
// data["clicks"] = [{ "t": "down"|"up", "button": 0, "atMs", "heldMs", "nx", "ny" }]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fn invoke(payload: &[u8]) -> Vec<u8> {
|
|
24
|
+
payload.to_vec()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fn event(_payload: &[u8]) {
|
|
28
|
+
// Grid events from the other CLIENT halves on this page (crowdy::api::emit_event).
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
crowdy::register_module!(init: init, tick: tick, invoke: invoke, event: event);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//! A hub: state that one instance per key owns, changed by one handler at a time.
|
|
2
|
+
//!
|
|
3
|
+
//! Endpoints: `add` (players add to the total) and `get` (anyone reads it).
|
|
4
|
+
|
|
5
|
+
use ckx_sdk::prelude::*;
|
|
6
|
+
|
|
7
|
+
#[derive(Default, Serialize, Deserialize)]
|
|
8
|
+
pub struct Counter {
|
|
9
|
+
pub total: i64,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
impl Hub for Counter {
|
|
13
|
+
fn spawn(_ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
|
|
14
|
+
Ok(Self::default())
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
|
|
18
|
+
decode(snapshot)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
|
|
22
|
+
encode(self)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fn handle(&mut self, _ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
26
|
+
match call.method {
|
|
27
|
+
"add" => {
|
|
28
|
+
call.player()?;
|
|
29
|
+
self.total += call.decode::<i64>()?;
|
|
30
|
+
encode(&self.total)
|
|
31
|
+
}
|
|
32
|
+
"get" => encode(&self.total),
|
|
33
|
+
other => Err(Error::unknown_method(other)),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
ckx_sdk::export_hub!(Counter);
|
|
39
|
+
|
|
40
|
+
#[cfg(test)]
|
|
41
|
+
mod tests {
|
|
42
|
+
use super::*;
|
|
43
|
+
|
|
44
|
+
fn ctx() -> Ctx {
|
|
45
|
+
Ctx { app: 1, instance: 1, epoch: 1, version: 1, kind: Kind::Hub, node_type: "counter".into(), key: "main".into() }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#[test]
|
|
49
|
+
fn players_add_and_anyone_reads() {
|
|
50
|
+
let ctx = ctx();
|
|
51
|
+
let mut hub = Counter::spawn(&ctx, &[]).unwrap();
|
|
52
|
+
let five = encode(&5i64).unwrap();
|
|
53
|
+
hub.handle(&ctx, Call { caller: Caller::Player(7), method: "add", payload: &five }).unwrap();
|
|
54
|
+
let total: i64 = decode(&hub.handle(&ctx, Call { caller: Caller::Instance(2), method: "get", payload: &[] }).unwrap()).unwrap();
|
|
55
|
+
assert_eq!(total, 5);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#[test]
|
|
59
|
+
fn only_players_add() {
|
|
60
|
+
let ctx = ctx();
|
|
61
|
+
let mut hub = Counter::spawn(&ctx, &[]).unwrap();
|
|
62
|
+
let one = encode(&1i64).unwrap();
|
|
63
|
+
assert!(hub.handle(&ctx, Call { caller: Caller::Instance(2), method: "add", payload: &one }).is_err());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#[test]
|
|
67
|
+
fn the_snapshot_restores_the_total() {
|
|
68
|
+
let ctx = ctx();
|
|
69
|
+
let mut hub = Counter { total: 42 };
|
|
70
|
+
let snapshot = hub.persist(&ctx).unwrap();
|
|
71
|
+
assert_eq!(Counter::load(&ctx, &snapshot, 1).unwrap().total, 42);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//! A spoke: stateless work that replicas run anywhere, changing hubs only through their
|
|
2
|
+
//! endpoints.
|
|
3
|
+
//!
|
|
4
|
+
//! Endpoint: `damage { attack, defense }` answers the damage of one hit.
|
|
5
|
+
|
|
6
|
+
use ckx_sdk::prelude::*;
|
|
7
|
+
|
|
8
|
+
#[derive(Serialize, Deserialize)]
|
|
9
|
+
pub struct Hit {
|
|
10
|
+
pub attack: u32,
|
|
11
|
+
pub defense: u32,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
pub fn damage(hit: &Hit) -> u32 {
|
|
15
|
+
hit.attack.saturating_sub(hit.defense / 2).max(1)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
pub struct Combat;
|
|
19
|
+
|
|
20
|
+
impl Spoke for Combat {
|
|
21
|
+
fn start(_ctx: &Ctx) -> Result<Self> {
|
|
22
|
+
Ok(Self)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fn handle(&mut self, _ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
26
|
+
match call.method {
|
|
27
|
+
"damage" => encode(&damage(&call.decode::<Hit>()?)),
|
|
28
|
+
other => Err(Error::unknown_method(other)),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
ckx_sdk::export_spoke!(Combat);
|
|
34
|
+
|
|
35
|
+
#[cfg(test)]
|
|
36
|
+
mod tests {
|
|
37
|
+
use super::*;
|
|
38
|
+
|
|
39
|
+
#[test]
|
|
40
|
+
fn a_hit_does_at_least_one_damage() {
|
|
41
|
+
assert_eq!(damage(&Hit { attack: 10, defense: 4 }), 8);
|
|
42
|
+
assert_eq!(damage(&Hit { attack: 1, defense: 100 }), 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[test]
|
|
46
|
+
fn the_endpoint_answers_in_messagepack() {
|
|
47
|
+
let ctx = Ctx { app: 1, instance: 1, epoch: 1, version: 1, kind: Kind::Spoke, node_type: "combat".into(), key: String::new() };
|
|
48
|
+
let mut spoke = Combat::start(&ctx).unwrap();
|
|
49
|
+
let hit = encode(&Hit { attack: 10, defense: 4 }).unwrap();
|
|
50
|
+
let reply = spoke.handle(&ctx, Call { caller: Caller::Instance(3), method: "damage", payload: &hit }).unwrap();
|
|
51
|
+
assert_eq!(decode::<u32>(&reply).unwrap(), 8);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# This file is automatically @generated by Cargo.
|
|
2
|
+
# It is not intended for manual editing.
|
|
3
|
+
version = 4
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "anyhow"
|
|
7
|
+
version = "1.0.104"
|
|
8
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
9
|
+
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
|
10
|
+
|
|
11
|
+
[[package]]
|
|
12
|
+
name = "autocfg"
|
|
13
|
+
version = "1.5.1"
|
|
14
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
15
|
+
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
|
16
|
+
|
|
17
|
+
[[package]]
|
|
18
|
+
name = "bitflags"
|
|
19
|
+
version = "2.13.2"
|
|
20
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
21
|
+
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
|
22
|
+
|
|
23
|
+
[[package]]
|
|
24
|
+
name = "equivalent"
|
|
25
|
+
version = "1.0.2"
|
|
26
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
27
|
+
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
|
28
|
+
|
|
29
|
+
[[package]]
|
|
30
|
+
name = "hashbrown"
|
|
31
|
+
version = "0.12.3"
|
|
32
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
33
|
+
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
|
34
|
+
|
|
35
|
+
[[package]]
|
|
36
|
+
name = "hashbrown"
|
|
37
|
+
version = "0.17.1"
|
|
38
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
39
|
+
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
|
40
|
+
|
|
41
|
+
[[package]]
|
|
42
|
+
name = "indexmap"
|
|
43
|
+
version = "1.9.3"
|
|
44
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
45
|
+
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
|
46
|
+
dependencies = [
|
|
47
|
+
"autocfg",
|
|
48
|
+
"hashbrown 0.12.3",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[[package]]
|
|
52
|
+
name = "indexmap"
|
|
53
|
+
version = "2.14.2"
|
|
54
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
55
|
+
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
|
56
|
+
dependencies = [
|
|
57
|
+
"equivalent",
|
|
58
|
+
"hashbrown 0.17.1",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[[package]]
|
|
62
|
+
name = "instrument"
|
|
63
|
+
version = "0.1.0"
|
|
64
|
+
dependencies = [
|
|
65
|
+
"anyhow",
|
|
66
|
+
"radix-wasm-instrument",
|
|
67
|
+
"wasm-encoder",
|
|
68
|
+
"wasmparser 0.107.0",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[[package]]
|
|
72
|
+
name = "leb128"
|
|
73
|
+
version = "0.2.7"
|
|
74
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
75
|
+
checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52"
|
|
76
|
+
|
|
77
|
+
[[package]]
|
|
78
|
+
name = "paste"
|
|
79
|
+
version = "1.0.15"
|
|
80
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
81
|
+
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
|
82
|
+
|
|
83
|
+
[[package]]
|
|
84
|
+
name = "radix-wasm-instrument"
|
|
85
|
+
version = "1.0.0"
|
|
86
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
87
|
+
checksum = "9b5e1305200330d3dd5c9b16ca43eba268993036ba19a2b2cadadee1b9166ab4"
|
|
88
|
+
dependencies = [
|
|
89
|
+
"anyhow",
|
|
90
|
+
"paste",
|
|
91
|
+
"wasm-encoder",
|
|
92
|
+
"wasmparser 0.107.0",
|
|
93
|
+
"wasmprinter",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
[[package]]
|
|
97
|
+
name = "semver"
|
|
98
|
+
version = "1.0.28"
|
|
99
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
100
|
+
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
|
101
|
+
|
|
102
|
+
[[package]]
|
|
103
|
+
name = "wasm-encoder"
|
|
104
|
+
version = "0.29.0"
|
|
105
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
106
|
+
checksum = "18c41dbd92eaebf3612a39be316540b8377c871cb9bde6b064af962984912881"
|
|
107
|
+
dependencies = [
|
|
108
|
+
"leb128",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
[[package]]
|
|
112
|
+
name = "wasmparser"
|
|
113
|
+
version = "0.107.0"
|
|
114
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
115
|
+
checksum = "29e3ac9b780c7dda0cac7a52a5d6d2d6707cc6e3451c9db209b6c758f40d7acb"
|
|
116
|
+
dependencies = [
|
|
117
|
+
"indexmap 1.9.3",
|
|
118
|
+
"semver",
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
[[package]]
|
|
122
|
+
name = "wasmparser"
|
|
123
|
+
version = "0.121.2"
|
|
124
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
125
|
+
checksum = "9dbe55c8f9d0dbd25d9447a5a889ff90c0cc3feaa7395310d3d826b2c703eaab"
|
|
126
|
+
dependencies = [
|
|
127
|
+
"bitflags",
|
|
128
|
+
"indexmap 2.14.2",
|
|
129
|
+
"semver",
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
[[package]]
|
|
133
|
+
name = "wasmprinter"
|
|
134
|
+
version = "0.2.80"
|
|
135
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
136
|
+
checksum = "60e73986a6b7fdfedb7c5bf9e7eb71135486507c8fbc4c0c42cffcb6532988b7"
|
|
137
|
+
dependencies = [
|
|
138
|
+
"anyhow",
|
|
139
|
+
"wasmparser 0.121.2",
|
|
140
|
+
]
|