@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.
Files changed (61) hide show
  1. package/README.md +13 -1
  2. package/crates/ckx-sdk/Cargo.toml +1 -1
  3. package/crates/ckx-sdk/src/kit/ai/brain.rs +288 -0
  4. package/crates/ckx-sdk/src/kit/ai/flow.rs +163 -0
  5. package/crates/ckx-sdk/src/kit/ai/mod.rs +10 -0
  6. package/crates/ckx-sdk/src/kit/ai/path.rs +214 -0
  7. package/crates/ckx-sdk/src/kit/ai/steering.rs +137 -0
  8. package/crates/ckx-sdk/src/kit/ai/turn.rs +141 -0
  9. package/crates/ckx-sdk/src/kit/mod.rs +542 -0
  10. package/crates/ckx-sdk/src/kit/play/abilities.rs +370 -0
  11. package/crates/ckx-sdk/src/kit/play/cards.rs +220 -0
  12. package/crates/ckx-sdk/src/kit/play/combat.rs +292 -0
  13. package/crates/ckx-sdk/src/kit/play/mod.rs +15 -0
  14. package/crates/ckx-sdk/src/kit/play/movement.rs +196 -0
  15. package/crates/ckx-sdk/src/kit/play/score.rs +183 -0
  16. package/crates/ckx-sdk/src/kit/play/timing.rs +218 -0
  17. package/crates/ckx-sdk/src/kit/play/turns.rs +315 -0
  18. package/crates/ckx-sdk/src/kit/presence.rs +142 -0
  19. package/crates/ckx-sdk/src/kit/rng.rs +137 -0
  20. package/crates/ckx-sdk/src/kit/shard.rs +65 -0
  21. package/crates/ckx-sdk/src/kit/sim/daycycle.rs +71 -0
  22. package/crates/ckx-sdk/src/kit/sim/mod.rs +10 -0
  23. package/crates/ckx-sdk/src/kit/sim/nodes.rs +109 -0
  24. package/crates/ckx-sdk/src/kit/sim/waves.rs +105 -0
  25. package/crates/ckx-sdk/src/kit/sim/weather.rs +142 -0
  26. package/crates/ckx-sdk/src/kit/sim/zones.rs +218 -0
  27. package/crates/ckx-sdk/src/kit/wire.rs +266 -0
  28. package/crates/ckx-sdk/src/lib.rs +13 -0
  29. package/crates/ckx-sdk/src/model.rs +5 -0
  30. package/crates/crowdy-client-sdk/Cargo.toml +1 -1
  31. package/dist/cli/src/new.js +9 -0
  32. package/kit.pin +4 -4
  33. package/package.json +1 -1
  34. package/starters/arena/Cargo.toml +14 -0
  35. package/starters/arena/src/lib.rs +849 -0
  36. package/starters/ckx.json +12 -1
  37. package/starters/deck/Cargo.toml +14 -0
  38. package/starters/deck/src/lib.rs +510 -0
  39. package/starters/director/Cargo.toml +14 -0
  40. package/starters/director/src/lib.rs +628 -0
  41. package/starters/grid-mod/Cargo.toml +1 -1
  42. package/starters/instance/Cargo.toml +14 -0
  43. package/starters/instance/src/lib.rs +656 -0
  44. package/starters/leaderboard/Cargo.toml +14 -0
  45. package/starters/leaderboard/src/lib.rs +395 -0
  46. package/starters/matchmaker/Cargo.toml +1 -1
  47. package/starters/matchmaker/src/lib.rs +334 -74
  48. package/starters/minigame/Cargo.toml +14 -0
  49. package/starters/minigame/src/lib.rs +391 -0
  50. package/starters/npc-mobs/Cargo.toml +1 -1
  51. package/starters/npc-mobs/src/lib.rs +277 -67
  52. package/starters/racing/Cargo.toml +14 -0
  53. package/starters/racing/src/lib.rs +1151 -0
  54. package/starters/session/Cargo.toml +1 -1
  55. package/starters/session/src/lib.rs +191 -86
  56. package/starters/tactics/Cargo.toml +14 -0
  57. package/starters/tactics/src/lib.rs +562 -0
  58. package/starters/territory/Cargo.toml +14 -0
  59. package/starters/territory/src/lib.rs +878 -0
  60. package/starters/world-tick/Cargo.toml +1 -1
  61. package/starters/world-tick/src/lib.rs +151 -38
@@ -0,0 +1,137 @@
1
+ //! A seeded random number generator (xorshift64*), plain data so it lives in a hub's state
2
+ //! and carries on after a snapshot. Seed it from something the hub already has: its key, the
3
+ //! clock at spawn, a match's seed.
4
+
5
+ use serde::{Deserialize, Serialize};
6
+
7
+ #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
8
+ pub struct Rng {
9
+ state: u64,
10
+ }
11
+
12
+ impl Default for Rng {
13
+ fn default() -> Self {
14
+ Self::seeded(1)
15
+ }
16
+ }
17
+
18
+ impl Rng {
19
+ /// A generator seeded with `seed`; 0 counts as 1, which xorshift needs.
20
+ pub fn seeded(seed: u64) -> Self {
21
+ Self { state: seed.max(1) }
22
+ }
23
+
24
+ /// A seed from text: a key, a name.
25
+ pub fn seed_from(text: &str) -> u64 {
26
+ // FNV-1a.
27
+ text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3))
28
+ }
29
+
30
+ pub fn next_u64(&mut self) -> u64 {
31
+ let mut s = self.state;
32
+ s ^= s >> 12;
33
+ s ^= s << 25;
34
+ s ^= s >> 27;
35
+ self.state = s;
36
+ s.wrapping_mul(0x2545_f491_4f6c_dd1d)
37
+ }
38
+
39
+ /// Uniform in `[0, 1)`.
40
+ pub fn f64(&mut self) -> f64 {
41
+ (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
42
+ }
43
+
44
+ /// Uniform in `[0, 1)`, single precision.
45
+ pub fn f32(&mut self) -> f32 {
46
+ (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
47
+ }
48
+
49
+ /// Uniform in `[lo, hi]`, both included: [`Rng::range`].
50
+ pub fn range_i64(&mut self, lo: i64, hi: i64) -> i64 {
51
+ self.range(lo, hi)
52
+ }
53
+
54
+ /// Uniform in `[0, n)`; 0 when `n` is 0.
55
+ pub fn below(&mut self, n: u64) -> u64 {
56
+ if n == 0 { 0 } else { self.next_u64() % n }
57
+ }
58
+
59
+ /// Uniform in `[lo, hi]`, both included; `lo` when `hi` is not above it.
60
+ pub fn range(&mut self, lo: i64, hi: i64) -> i64 {
61
+ if hi <= lo {
62
+ return lo;
63
+ }
64
+ let span = hi.abs_diff(lo).saturating_add(1);
65
+ lo.wrapping_add(self.below(span) as i64)
66
+ }
67
+
68
+ /// True with probability `p` (clamped to `[0, 1]`).
69
+ pub fn chance(&mut self, p: f64) -> bool {
70
+ self.f64() < p.clamp(0.0, 1.0)
71
+ }
72
+
73
+ pub fn pick<'a, T>(&mut self, items: &'a [T]) -> Option<&'a T> {
74
+ if items.is_empty() { None } else { items.get(self.below(items.len() as u64) as usize) }
75
+ }
76
+
77
+ /// The index of one weight, chosen in proportion to the weights; `None` when they sum to
78
+ /// nothing.
79
+ pub fn weighted(&mut self, weights: &[u64]) -> Option<usize> {
80
+ let total: u64 = weights.iter().fold(0u64, |a, w| a.saturating_add(*w));
81
+ if total == 0 {
82
+ return None;
83
+ }
84
+ let mut roll = self.below(total);
85
+ for (i, w) in weights.iter().enumerate() {
86
+ if roll < *w {
87
+ return Some(i);
88
+ }
89
+ roll -= w;
90
+ }
91
+ None
92
+ }
93
+
94
+ /// Shuffles in place (Fisher-Yates).
95
+ pub fn shuffle<T>(&mut self, items: &mut [T]) {
96
+ for i in (1..items.len()).rev() {
97
+ let j = self.below(i as u64 + 1) as usize;
98
+ items.swap(i, j);
99
+ }
100
+ }
101
+ }
102
+
103
+ #[cfg(test)]
104
+ mod tests {
105
+ use super::*;
106
+
107
+ #[test]
108
+ fn deterministic_and_bounded() {
109
+ let (mut a, mut b) = (Rng::seeded(42), Rng::seeded(42));
110
+ for _ in 0..100 {
111
+ assert_eq!(a.next_u64(), b.next_u64());
112
+ }
113
+ for _ in 0..1_000 {
114
+ assert!((0.0..1.0).contains(&a.f64()));
115
+ assert!((-5..=5).contains(&a.range(-5, 5)));
116
+ assert!(a.below(3) < 3);
117
+ }
118
+ // The widest span does not overflow.
119
+ let _ = a.range(i64::MIN, i64::MAX);
120
+ assert_eq!(a.range(4, 4), 4);
121
+ assert!(a.pick::<u8>(&[]).is_none());
122
+ assert_eq!(a.weighted(&[0, 0]), None);
123
+ assert_eq!(a.weighted(&[0, 5, 0]), Some(1));
124
+ }
125
+
126
+ #[test]
127
+ fn a_shuffle_keeps_every_item_and_seeds_from_text_are_stable() {
128
+ let mut items: Vec<u32> = (0..52).collect();
129
+ Rng::seeded(Rng::seed_from("table-1")).shuffle(&mut items);
130
+ let mut sorted = items.clone();
131
+ sorted.sort_unstable();
132
+ assert_eq!(sorted, (0..52).collect::<Vec<_>>());
133
+ assert_ne!(items, sorted);
134
+ assert_eq!(Rng::seed_from("a"), Rng::seed_from("a"));
135
+ assert_ne!(Rng::seed_from("a"), Rng::seed_from("b"));
136
+ }
137
+ }
@@ -0,0 +1,65 @@
1
+ //! Shard keys: one keyed hub per square of the world, so no one hub holds everyone.
2
+ //!
3
+ //! A shard is `size` by `size` chunks on the x and z axes, and its key is its centre chunk,
4
+ //! `"<x>,<z>"`. Players' clients compute the same keys (the kit clients carry this function),
5
+ //! so a client finds the hub for where it stands without asking anyone. A hub that sees its
6
+ //! neighbours' edges subscribes to [`around`]'s keys.
7
+
8
+ /// The shard's centre chunk for chunk `(x, z)`. `size` is at least 1.
9
+ pub fn centre(x: i64, z: i64, size: i64) -> (i64, i64) {
10
+ let size = size.max(1);
11
+ (x.div_euclid(size) * size + size / 2, z.div_euclid(size) * size + size / 2)
12
+ }
13
+
14
+ /// The key of the shard that holds chunk `(x, z)`.
15
+ pub fn key(x: i64, z: i64, size: i64) -> String {
16
+ let (cx, cz) = centre(x, z, size);
17
+ format!("{cx},{cz}")
18
+ }
19
+
20
+ /// The centre chunk a key names, or `None` when it is not `"<x>,<z>"`.
21
+ pub fn parse(key: &str) -> Option<(i64, i64)> {
22
+ let (x, z) = key.split_once(',')?;
23
+ Some((x.trim().parse().ok()?, z.trim().parse().ok()?))
24
+ }
25
+
26
+ /// The keys of the shards within `radius` shards of the one holding `(x, z)`, its own first,
27
+ /// then row by row.
28
+ pub fn around(x: i64, z: i64, size: i64, radius: i64) -> Vec<String> {
29
+ let size = size.max(1);
30
+ let (cx, cz) = centre(x, z, size);
31
+ let mut keys = vec![format!("{cx},{cz}")];
32
+ for dz in -radius..=radius {
33
+ for dx in -radius..=radius {
34
+ if (dx, dz) != (0, 0) {
35
+ keys.push(format!("{},{}", cx + dx * size, cz + dz * size));
36
+ }
37
+ }
38
+ }
39
+ keys
40
+ }
41
+
42
+ #[cfg(test)]
43
+ mod tests {
44
+ use super::*;
45
+
46
+ #[test]
47
+ fn a_chunk_falls_in_one_shard_named_by_its_centre() {
48
+ assert_eq!(key(10, -4, 8), "12,-4");
49
+ assert_eq!(key(8, -8, 8), "12,-4");
50
+ assert_eq!(key(7, -9, 8), "4,-12");
51
+ assert_eq!(key(-1, 0, 16), "-8,8");
52
+ assert_eq!(key(3, 5, 1), "3,5", "size 1 is the chunk itself");
53
+ assert_eq!(parse("12,-4"), Some((12, -4)));
54
+ assert_eq!(parse("x"), None);
55
+ }
56
+
57
+ #[test]
58
+ fn around_starts_with_its_own_shard() {
59
+ let keys = around(0, 0, 8, 1);
60
+ assert_eq!(keys.len(), 9);
61
+ assert_eq!(keys[0], "4,4");
62
+ assert!(keys.contains(&"-4,-4".to_string()) && keys.contains(&"12,12".to_string()));
63
+ assert_eq!(around(0, 0, 8, 0), vec!["4,4".to_string()]);
64
+ }
65
+ }
@@ -0,0 +1,71 @@
1
+ //! The deterministic day/night clock: a pure function of wall-clock ms, so
2
+ //! hubs and clients agree without any synchronization message.
3
+ //! A 20-minute day is the
4
+ //! default; games pick their own length.
5
+
6
+ use serde::{Deserialize, Serialize};
7
+
8
+ pub const DEFAULT_DAY_LENGTH_MS: u64 = 1_200_000; // 20 minutes
9
+
10
+ #[derive(Serialize, Deserialize, Clone, Copy, Debug)]
11
+ pub struct DayCycle {
12
+ pub day_length_ms: u64,
13
+ }
14
+
15
+ impl Default for DayCycle {
16
+ fn default() -> Self {
17
+ Self { day_length_ms: DEFAULT_DAY_LENGTH_MS }
18
+ }
19
+ }
20
+
21
+ impl DayCycle {
22
+ pub fn new(day_length_ms: u64) -> Self {
23
+ Self { day_length_ms: day_length_ms.max(1) }
24
+ }
25
+
26
+ /// Phase in [0, 1): 0 = day start, 0.5 = day/night boundary region.
27
+ pub fn phase(&self, now_ms: u64) -> f32 {
28
+ (now_ms % self.day_length_ms) as f32 / self.day_length_ms as f32
29
+ }
30
+
31
+ /// Night when sin(phase*TAU) dips below -0.05.
32
+ pub fn is_night(&self, now_ms: u64) -> bool {
33
+ (self.phase(now_ms) * std::f32::consts::TAU).sin() < -0.05
34
+ }
35
+
36
+ /// Sun height in [-1, 1] (drive lighting / spawn weights).
37
+ pub fn sun(&self, now_ms: u64) -> f32 {
38
+ (self.phase(now_ms) * std::f32::consts::TAU).sin()
39
+ }
40
+
41
+ /// Day number since epoch (season math, daily resets).
42
+ pub fn day_index(&self, now_ms: u64) -> u64 {
43
+ now_ms / self.day_length_ms
44
+ }
45
+ }
46
+
47
+ #[cfg(test)]
48
+ mod tests {
49
+ use super::*;
50
+
51
+ #[test]
52
+ fn a_twenty_minute_day_turns_to_night_and_back() {
53
+ let cycle = DayCycle::default();
54
+ // First half of the cycle: sun up (sin > 0).
55
+ assert!(!cycle.is_night(60_000));
56
+ assert!(cycle.sun(300_000) > 0.99); // quarter cycle = noon
57
+ // Second half: night.
58
+ assert!(cycle.is_night(900_000)); // three-quarter = midnight
59
+ assert_eq!(cycle.day_index(2_500_000), 2);
60
+ }
61
+
62
+ #[test]
63
+ fn deterministic_across_instances() {
64
+ let a = DayCycle::new(600_000);
65
+ let b = DayCycle::new(600_000);
66
+ for t in (0..3_000_000).step_by(90_000) {
67
+ assert_eq!(a.is_night(t), b.is_night(t));
68
+ assert_eq!(a.phase(t), b.phase(t));
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,10 @@
1
+ //! World simulation a hub runs: [`daycycle`] (a clock everyone agrees on from the time alone),
2
+ //! [`weather`] (seeded fronts), [`nodes`] (resource nodes that deplete and regrow), [`waves`]
3
+ //! (timed spawn waves) and [`zones`] (rule boxes and shrinking circles). Deterministic over
4
+ //! `now_ms` and a seeded [`Rng`](super::Rng).
5
+
6
+ pub mod daycycle;
7
+ pub mod nodes;
8
+ pub mod waves;
9
+ pub mod weather;
10
+ pub mod zones;
@@ -0,0 +1,109 @@
1
+ //! Resource nodes: harvest/deplete/respawn lifecycle (ore veins, berry
2
+ //! bushes, fishing spots). Pure state machine — a hub keeps the pool in
3
+ //! its state.
4
+
5
+ use serde::{Deserialize, Serialize};
6
+ use std::collections::BTreeMap;
7
+
8
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
9
+ pub enum NodePhase {
10
+ #[default]
11
+ Ready,
12
+ /// Depleted; respawns at the embedded time.
13
+ Respawning { at_ms: u64 },
14
+ }
15
+
16
+ #[derive(Serialize, Deserialize, Clone, Debug, Default)]
17
+ pub struct Node {
18
+ pub phase: NodePhase,
19
+ /// Remaining harvests before depletion (0 = infinite until depleted
20
+ /// explicitly).
21
+ pub charges: u32,
22
+ }
23
+
24
+ #[derive(Serialize, Deserialize, Clone, Debug, Default)]
25
+ pub struct NodePool {
26
+ pub nodes: BTreeMap<String, Node>,
27
+ }
28
+
29
+ pub struct HarvestOutcome {
30
+ pub harvested: bool,
31
+ pub depleted: bool,
32
+ }
33
+
34
+ impl NodePool {
35
+ /// Register a node (idempotent; keeps existing state).
36
+ pub fn ensure(&mut self, id: &str, charges: u32) -> &mut Node {
37
+ self.nodes.entry(id.to_string()).or_insert(Node { phase: NodePhase::Ready, charges })
38
+ }
39
+
40
+ /// Attempt a harvest. Depletes when charges run out.
41
+ pub fn harvest(&mut self, id: &str, now_ms: u64, respawn_ms: u64) -> HarvestOutcome {
42
+ let Some(node) = self.nodes.get_mut(id) else {
43
+ return HarvestOutcome { harvested: false, depleted: false };
44
+ };
45
+ if !matches!(node.phase, NodePhase::Ready) {
46
+ return HarvestOutcome { harvested: false, depleted: false };
47
+ }
48
+ let depleted = if node.charges > 0 {
49
+ node.charges -= 1;
50
+ node.charges == 0
51
+ } else {
52
+ false
53
+ };
54
+ if depleted {
55
+ node.phase = NodePhase::Respawning { at_ms: now_ms + respawn_ms };
56
+ }
57
+ HarvestOutcome { harvested: true, depleted }
58
+ }
59
+
60
+ /// Tick respawns: returns ids that just came back (re-arm charges).
61
+ pub fn tick(&mut self, now_ms: u64, recharge: u32) -> Vec<String> {
62
+ let mut respawned = Vec::new();
63
+ for (id, node) in &mut self.nodes {
64
+ if let NodePhase::Respawning { at_ms } = node.phase
65
+ && now_ms >= at_ms
66
+ {
67
+ node.phase = NodePhase::Ready;
68
+ node.charges = recharge;
69
+ respawned.push(id.clone());
70
+ }
71
+ }
72
+ respawned
73
+ }
74
+
75
+ pub fn is_ready(&self, id: &str) -> bool {
76
+ matches!(self.nodes.get(id), Some(Node { phase: NodePhase::Ready, .. }))
77
+ }
78
+ }
79
+
80
+ #[cfg(test)]
81
+ mod tests {
82
+ use super::*;
83
+
84
+ #[test]
85
+ fn harvest_deplete_respawn() {
86
+ let mut pool = NodePool::default();
87
+ pool.ensure("vein-1", 2);
88
+ assert!(pool.harvest("vein-1", 1_000, 30_000).harvested);
89
+ let second = pool.harvest("vein-1", 2_000, 30_000);
90
+ assert!(second.harvested && second.depleted);
91
+ // Depleted: further harvests fail; not ready.
92
+ assert!(!pool.harvest("vein-1", 3_000, 30_000).harvested);
93
+ assert!(!pool.is_ready("vein-1"));
94
+ // Respawn on schedule with recharged charges.
95
+ assert!(pool.tick(31_000, 2).is_empty());
96
+ assert_eq!(pool.tick(32_000, 2), vec!["vein-1".to_string()]);
97
+ assert!(pool.harvest("vein-1", 33_000, 30_000).harvested);
98
+ }
99
+
100
+ #[test]
101
+ fn infinite_nodes_never_self_deplete() {
102
+ let mut pool = NodePool::default();
103
+ pool.ensure("well", 0);
104
+ for t in 0..50 {
105
+ let outcome = pool.harvest("well", t, 1_000);
106
+ assert!(outcome.harvested && !outcome.depleted);
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,105 @@
1
+ //! Wave schedules: primitives for timed spawn waves (tower defense, horde
2
+ //! nights): the scheduling core a director hub drives.
3
+
4
+ use serde::{Deserialize, Serialize};
5
+
6
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
7
+ pub struct WaveDef {
8
+ /// Entries: (unit id, count).
9
+ pub units: Vec<(String, u32)>,
10
+ /// Delay after the previous wave clears (or starts, for timed mode).
11
+ pub delay_ms: u64,
12
+ }
13
+
14
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
15
+ pub enum WavePhase {
16
+ /// Before wave `next` starts; starts at the embedded time.
17
+ Countdown { next: usize, starts_at_ms: u64 },
18
+ /// Wave `index` is live.
19
+ Active { index: usize },
20
+ /// All waves done.
21
+ Complete,
22
+ }
23
+
24
+ #[derive(Serialize, Deserialize, Clone, Debug)]
25
+ pub struct WaveSchedule {
26
+ pub phase: WavePhase,
27
+ }
28
+
29
+ impl Default for WaveSchedule {
30
+ fn default() -> Self {
31
+ Self { phase: WavePhase::Complete }
32
+ }
33
+ }
34
+
35
+ pub enum WaveEvent {
36
+ /// Spawn this wave's units now.
37
+ Start(usize),
38
+ Finished,
39
+ }
40
+
41
+ impl WaveSchedule {
42
+ /// Arm the schedule from wave 0 with the first delay.
43
+ pub fn start(waves: &[WaveDef], now_ms: u64) -> Self {
44
+ match waves.first() {
45
+ Some(first) => Self { phase: WavePhase::Countdown { next: 0, starts_at_ms: now_ms + first.delay_ms } },
46
+ None => Self { phase: WavePhase::Complete },
47
+ }
48
+ }
49
+
50
+ /// Advance. `active_cleared` = the live wave's units are all dead.
51
+ pub fn step(&mut self, waves: &[WaveDef], now_ms: u64, active_cleared: bool) -> Option<WaveEvent> {
52
+ match self.phase.clone() {
53
+ WavePhase::Countdown { next, starts_at_ms } if now_ms >= starts_at_ms => {
54
+ self.phase = WavePhase::Active { index: next };
55
+ Some(WaveEvent::Start(next))
56
+ }
57
+ WavePhase::Active { index } if active_cleared => {
58
+ let next = index + 1;
59
+ match waves.get(next) {
60
+ Some(wave) => {
61
+ self.phase = WavePhase::Countdown { next, starts_at_ms: now_ms + wave.delay_ms };
62
+ None
63
+ }
64
+ None => {
65
+ self.phase = WavePhase::Complete;
66
+ Some(WaveEvent::Finished)
67
+ }
68
+ }
69
+ }
70
+ _ => None,
71
+ }
72
+ }
73
+ }
74
+
75
+ #[cfg(test)]
76
+ mod tests {
77
+ use super::*;
78
+
79
+ fn two_waves() -> Vec<WaveDef> {
80
+ vec![
81
+ WaveDef { units: vec![("slime".into(), 3)], delay_ms: 5_000 },
82
+ WaveDef { units: vec![("skeleton".into(), 2)], delay_ms: 10_000 },
83
+ ]
84
+ }
85
+
86
+ #[test]
87
+ fn full_run() {
88
+ let waves = two_waves();
89
+ let mut schedule = WaveSchedule::start(&waves, 0);
90
+ assert!(schedule.step(&waves, 4_999, false).is_none());
91
+ assert!(matches!(schedule.step(&waves, 5_000, false), Some(WaveEvent::Start(0))));
92
+ // Wave 0 live until cleared.
93
+ assert!(schedule.step(&waves, 8_000, false).is_none());
94
+ assert!(schedule.step(&waves, 9_000, true).is_none()); // countdown to wave 1
95
+ assert!(matches!(schedule.step(&waves, 19_000, false), Some(WaveEvent::Start(1))));
96
+ assert!(matches!(schedule.step(&waves, 25_000, true), Some(WaveEvent::Finished)));
97
+ assert_eq!(schedule.phase, WavePhase::Complete);
98
+ }
99
+
100
+ #[test]
101
+ fn empty_schedule_completes() {
102
+ let schedule = WaveSchedule::start(&[], 0);
103
+ assert_eq!(schedule.phase, WavePhase::Complete);
104
+ }
105
+ }
@@ -0,0 +1,142 @@
1
+ //! Weather fronts: a seeded state machine that walks through weather kinds
2
+ //! with randomized durations, plus broadcast helpers. Clients receive
3
+ //! transitions as type-90 server events (see `EVENT_WEATHER`) and render
4
+ //! locally; late joiners read the current front from the hub.
5
+
6
+ use serde::{Deserialize, Serialize};
7
+ use serde_json::{Value, json};
8
+
9
+ use crate::kit::Rng;
10
+ use crate::kit::wire;
11
+ use crate::node::ChunkPos;
12
+ use crate::{Ctx, Result};
13
+
14
+ /// The platform-reserved server-event type for weather/season transitions.
15
+ pub const EVENT_WEATHER: u16 = 90;
16
+
17
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
18
+ pub struct WeatherKind {
19
+ pub id: String,
20
+ /// Relative pick weight (0 disables).
21
+ pub weight: u32,
22
+ pub min_ms: u64,
23
+ pub max_ms: u64,
24
+ }
25
+
26
+ pub fn default_kinds() -> Vec<WeatherKind> {
27
+ vec![
28
+ WeatherKind { id: "clear".into(), weight: 6, min_ms: 300_000, max_ms: 900_000 },
29
+ WeatherKind { id: "cloudy".into(), weight: 3, min_ms: 180_000, max_ms: 600_000 },
30
+ WeatherKind { id: "rain".into(), weight: 2, min_ms: 120_000, max_ms: 480_000 },
31
+ WeatherKind { id: "storm".into(), weight: 1, min_ms: 60_000, max_ms: 240_000 },
32
+ ]
33
+ }
34
+
35
+ #[derive(Serialize, Deserialize, Clone, Debug, Default)]
36
+ pub struct WeatherState {
37
+ pub current: String,
38
+ pub since_ms: u64,
39
+ pub until_ms: u64,
40
+ }
41
+
42
+ pub struct Transition {
43
+ pub from: String,
44
+ pub to: String,
45
+ pub until_ms: u64,
46
+ }
47
+
48
+ /// Advance the front. Returns the transition when the front changed (emit it
49
+ /// to clients), `None` while the current front holds.
50
+ pub fn step(state: &mut WeatherState, kinds: &[WeatherKind], now_ms: u64, rng: &mut Rng) -> Option<Transition> {
51
+ if !state.current.is_empty() && now_ms < state.until_ms {
52
+ return None;
53
+ }
54
+ let total: u32 = kinds.iter().map(|k| k.weight).sum();
55
+ if total == 0 {
56
+ return None;
57
+ }
58
+ // Weighted pick, avoiding an immediate repeat when there's a choice.
59
+ let mut pick = || {
60
+ let mut roll = (rng.next_u64() % total as u64) as u32;
61
+ for kind in kinds {
62
+ if roll < kind.weight {
63
+ return kind.clone();
64
+ }
65
+ roll -= kind.weight;
66
+ }
67
+ kinds[0].clone()
68
+ };
69
+ let mut next = pick();
70
+ if next.id == state.current && kinds.iter().filter(|k| k.weight > 0).count() > 1 {
71
+ for _ in 0..4 {
72
+ next = pick();
73
+ if next.id != state.current {
74
+ break;
75
+ }
76
+ }
77
+ }
78
+ let duration = next.min_ms + rng.next_u64() % (next.max_ms.saturating_sub(next.min_ms).max(1));
79
+ let from = std::mem::replace(&mut state.current, next.id.clone());
80
+ state.since_ms = now_ms;
81
+ state.until_ms = now_ms + duration;
82
+ Some(Transition { from, to: next.id, until_ms: state.until_ms })
83
+ }
84
+
85
+ /// The client-facing JSON for a front (both the event body and `forecast`).
86
+ pub fn front_json(state: &WeatherState, now_ms: u64) -> Value {
87
+ json!({
88
+ "weather": state.current,
89
+ "sinceMs": state.since_ms,
90
+ "untilMs": state.until_ms,
91
+ "remainingMs": state.until_ms.saturating_sub(now_ms),
92
+ })
93
+ }
94
+
95
+ /// Sends a transition as a type-90 server event to the players within `distance` chunks of
96
+ /// `at`, from the hub's `actor`.
97
+ pub fn emit_transition(ctx: &Ctx, at: ChunkPos, distance: u8, actor: &[u8; 32], state: &WeatherState, now_ms: u64) -> Result<()> {
98
+ wire::emit_event(ctx, at, distance, actor, EVENT_WEATHER, &front_json(state, now_ms))
99
+ }
100
+
101
+ #[cfg(test)]
102
+ mod tests {
103
+ use super::*;
104
+
105
+ #[test]
106
+ fn fronts_advance_and_hold() {
107
+ let kinds = default_kinds();
108
+ let mut state = WeatherState::default();
109
+ let mut rng = Rng::seeded(7);
110
+ let t = step(&mut state, &kinds, 1_000, &mut rng).expect("first step transitions");
111
+ assert_eq!(t.from, "");
112
+ assert!(!state.current.is_empty());
113
+ assert!(state.until_ms > 1_000);
114
+ // Holds until the front expires.
115
+ let until = state.until_ms;
116
+ assert!(step(&mut state, &kinds, until - 1, &mut rng).is_none());
117
+ let prev = state.current.clone();
118
+ let t2 = step(&mut state, &kinds, until, &mut rng).expect("expiry transitions");
119
+ assert_eq!(t2.from, prev);
120
+ assert_ne!(t2.to, prev, "avoids immediate repeats");
121
+ }
122
+
123
+ #[test]
124
+ fn deterministic_for_a_seed() {
125
+ let kinds = default_kinds();
126
+ let mut a = (WeatherState::default(), Rng::seeded(42));
127
+ let mut b = (WeatherState::default(), Rng::seeded(42));
128
+ for t in (0..10_000_000u64).step_by(60_000) {
129
+ let ta = step(&mut a.0, &kinds, t, &mut a.1).map(|x| x.to);
130
+ let tb = step(&mut b.0, &kinds, t, &mut b.1).map(|x| x.to);
131
+ assert_eq!(ta, tb);
132
+ }
133
+ }
134
+
135
+ #[test]
136
+ fn front_json_shape() {
137
+ let state = WeatherState { current: "rain".into(), since_ms: 100, until_ms: 5_100 };
138
+ let v = front_json(&state, 1_100);
139
+ assert_eq!(v["weather"], "rain");
140
+ assert_eq!(v["remainingMs"], 4_000);
141
+ }
142
+ }