@crowdedkingdoms/ckx-kit 0.3.0-dev.1 → 0.5.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +9 -2
  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/econ/holdings.rs +241 -0
  10. package/crates/ckx-sdk/src/kit/econ/holds.rs +202 -0
  11. package/crates/ckx-sdk/src/kit/econ/items.rs +212 -0
  12. package/crates/ckx-sdk/src/kit/econ/loot.rs +116 -0
  13. package/crates/ckx-sdk/src/kit/econ/mod.rs +50 -0
  14. package/crates/ckx-sdk/src/kit/econ/orderbook.rs +155 -0
  15. package/crates/ckx-sdk/src/kit/econ/production.rs +155 -0
  16. package/crates/ckx-sdk/src/kit/econ/progression.rs +254 -0
  17. package/crates/ckx-sdk/src/kit/econ/recipes.rs +65 -0
  18. package/crates/ckx-sdk/src/kit/econ/vendor.rs +116 -0
  19. package/crates/ckx-sdk/src/kit/econ/wallet.rs +67 -0
  20. package/crates/ckx-sdk/src/kit/mod.rs +72 -3
  21. package/crates/ckx-sdk/src/kit/play/abilities.rs +370 -0
  22. package/crates/ckx-sdk/src/kit/play/cards.rs +220 -0
  23. package/crates/ckx-sdk/src/kit/play/combat.rs +292 -0
  24. package/crates/ckx-sdk/src/kit/play/mod.rs +15 -0
  25. package/crates/ckx-sdk/src/kit/play/movement.rs +196 -0
  26. package/crates/ckx-sdk/src/kit/play/score.rs +183 -0
  27. package/crates/ckx-sdk/src/kit/play/timing.rs +218 -0
  28. package/crates/ckx-sdk/src/kit/play/turns.rs +315 -0
  29. package/crates/ckx-sdk/src/kit/presence.rs +142 -0
  30. package/crates/ckx-sdk/src/kit/rng.rs +10 -0
  31. package/crates/ckx-sdk/src/kit/sim/daycycle.rs +71 -0
  32. package/crates/ckx-sdk/src/kit/sim/mod.rs +10 -0
  33. package/crates/ckx-sdk/src/kit/sim/nodes.rs +109 -0
  34. package/crates/ckx-sdk/src/kit/sim/waves.rs +105 -0
  35. package/crates/ckx-sdk/src/kit/sim/weather.rs +142 -0
  36. package/crates/ckx-sdk/src/kit/sim/zones.rs +218 -0
  37. package/crates/ckx-sdk/src/lib.rs +2 -1
  38. package/dist/cli/src/new.js +11 -0
  39. package/kit.pin +4 -4
  40. package/package.json +1 -1
  41. package/starters/arena/Cargo.toml +14 -0
  42. package/starters/arena/src/lib.rs +849 -0
  43. package/starters/ckx.json +14 -1
  44. package/starters/deck/Cargo.toml +14 -0
  45. package/starters/deck/src/lib.rs +510 -0
  46. package/starters/director/Cargo.toml +14 -0
  47. package/starters/director/src/lib.rs +628 -0
  48. package/starters/grid-mod/Cargo.toml +1 -1
  49. package/starters/instance/Cargo.toml +14 -0
  50. package/starters/instance/src/lib.rs +656 -0
  51. package/starters/leaderboard/Cargo.toml +14 -0
  52. package/starters/leaderboard/src/lib.rs +395 -0
  53. package/starters/market/Cargo.toml +14 -0
  54. package/starters/market/src/lib.rs +1137 -0
  55. package/starters/matchmaker/Cargo.toml +1 -1
  56. package/starters/minigame/Cargo.toml +14 -0
  57. package/starters/minigame/src/lib.rs +391 -0
  58. package/starters/npc-mobs/Cargo.toml +1 -1
  59. package/starters/player/Cargo.toml +14 -0
  60. package/starters/player/src/lib.rs +994 -0
  61. package/starters/racing/Cargo.toml +14 -0
  62. package/starters/racing/src/lib.rs +1151 -0
  63. package/starters/session/Cargo.toml +1 -1
  64. package/starters/tactics/Cargo.toml +14 -0
  65. package/starters/tactics/src/lib.rs +562 -0
  66. package/starters/territory/Cargo.toml +14 -0
  67. package/starters/territory/src/lib.rs +878 -0
  68. package/starters/world-tick/Cargo.toml +1 -1
@@ -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
+ }
@@ -0,0 +1,218 @@
1
+ //! Rule zones: static axis-aligned boxes tagged with rule strings ("safe",
2
+ //! "pvp", "build-locked") plus **shrinking circles** — the
3
+ //! battle-royale zone: a phased schedule of (radius, hold, shrink) steps
4
+ //! with warning/shrink/settle events for the type-98 lane.
5
+
6
+ use serde::{Deserialize, Serialize};
7
+
8
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
9
+ pub struct Zone {
10
+ pub id: String,
11
+ pub rule: String,
12
+ pub min: (f32, f32, f32),
13
+ pub max: (f32, f32, f32),
14
+ }
15
+
16
+ impl Zone {
17
+ pub fn contains(&self, pos: (f32, f32, f32)) -> bool {
18
+ pos.0 >= self.min.0
19
+ && pos.0 <= self.max.0
20
+ && pos.1 >= self.min.1
21
+ && pos.1 <= self.max.1
22
+ && pos.2 >= self.min.2
23
+ && pos.2 <= self.max.2
24
+ }
25
+ }
26
+
27
+ #[derive(Serialize, Deserialize, Clone, Debug, Default)]
28
+ pub struct ZoneSet {
29
+ pub zones: Vec<Zone>,
30
+ }
31
+
32
+ impl ZoneSet {
33
+ /// All zones containing the position.
34
+ pub fn at(&self, pos: (f32, f32, f32)) -> Vec<&Zone> {
35
+ self.zones.iter().filter(|z| z.contains(pos)).collect()
36
+ }
37
+
38
+ /// Does any zone with this rule cover the position?
39
+ pub fn has_rule(&self, pos: (f32, f32, f32), rule: &str) -> bool {
40
+ self.zones.iter().any(|z| z.rule == rule && z.contains(pos))
41
+ }
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Shrinking circles (BR zones)
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /// One phase of a shrinking-zone schedule.
49
+ #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
50
+ pub struct ShrinkPhase {
51
+ /// The radius this phase shrinks TO.
52
+ pub radius: f32,
53
+ /// How long the zone HOLDS before shrinking (the warning window).
54
+ pub hold_ms: u64,
55
+ /// How long the shrink itself takes (radius interpolates linearly).
56
+ pub shrink_ms: u64,
57
+ }
58
+
59
+ /// Announcements a shrinking zone produces as time advances.
60
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
61
+ pub enum ZoneEvent {
62
+ /// A hold began: the zone will start shrinking to `to_radius` at `at_ms`.
63
+ Warning { phase: usize, to_radius: f32, shrink_starts_at_ms: u64 },
64
+ /// The shrink began.
65
+ Shrinking { phase: usize, to_radius: f32 },
66
+ /// The phase's target radius was reached.
67
+ Settled { phase: usize, radius: f32 },
68
+ /// Every phase is done (the final circle).
69
+ Final { radius: f32 },
70
+ }
71
+
72
+ /// A battle-royale circle: fixed center, radius stepping down a schedule.
73
+ /// Pure clock math — feed it `now_ms`, read `radius()` and drain events.
74
+ #[derive(Serialize, Deserialize, Clone, Debug, Default)]
75
+ pub struct ShrinkingZone {
76
+ pub center: (f32, f32),
77
+ pub initial_radius: f32,
78
+ pub phases: Vec<ShrinkPhase>,
79
+ pub started_at_ms: u64,
80
+ /// Last phase-progress marker events were emitted for (phase, stage).
81
+ announced: Vec<(usize, u8)>,
82
+ }
83
+
84
+ impl ShrinkingZone {
85
+ pub fn start(center: (f32, f32), initial_radius: f32, phases: Vec<ShrinkPhase>, now_ms: u64) -> Self {
86
+ Self { center, initial_radius, phases, started_at_ms: now_ms, announced: Vec::new() }
87
+ }
88
+
89
+ /// Phase timeline offsets: each phase = hold then shrink.
90
+ fn phase_bounds(&self, phase: usize) -> (u64, u64, u64) {
91
+ let mut offset = 0u64;
92
+ for p in &self.phases[..phase] {
93
+ offset += p.hold_ms + p.shrink_ms;
94
+ }
95
+ let p = &self.phases[phase];
96
+ (offset, offset + p.hold_ms, offset + p.hold_ms + p.shrink_ms)
97
+ }
98
+
99
+ /// The circle's radius at `now_ms` (linear interpolation during shrinks).
100
+ pub fn radius(&self, now_ms: u64) -> f32 {
101
+ let elapsed = now_ms.saturating_sub(self.started_at_ms);
102
+ let mut from = self.initial_radius;
103
+ for (index, phase) in self.phases.iter().enumerate() {
104
+ let (_, shrink_start, shrink_end) = self.phase_bounds(index);
105
+ if elapsed < shrink_start {
106
+ return from;
107
+ }
108
+ if elapsed < shrink_end {
109
+ let t = (elapsed - shrink_start) as f32 / (shrink_end - shrink_start).max(1) as f32;
110
+ return from + (phase.radius - from) * t;
111
+ }
112
+ from = phase.radius;
113
+ }
114
+ from
115
+ }
116
+
117
+ /// Is a position inside the live circle?
118
+ pub fn contains(&self, pos: (f32, f32), now_ms: u64) -> bool {
119
+ let (dx, dz) = (pos.0 - self.center.0, pos.1 - self.center.1);
120
+ (dx * dx + dz * dz).sqrt() <= self.radius(now_ms)
121
+ }
122
+
123
+ /// Every phase fully settled?
124
+ pub fn done(&self, now_ms: u64) -> bool {
125
+ match self.phases.len() {
126
+ 0 => true,
127
+ n => now_ms.saturating_sub(self.started_at_ms) >= self.phase_bounds(n - 1).2,
128
+ }
129
+ }
130
+
131
+ /// Advance the announcer: returns the events newly due at `now_ms`
132
+ /// (warning at hold start, shrinking at shrink start, settled at end,
133
+ /// final once after the last phase).
134
+ pub fn poll(&mut self, now_ms: u64) -> Vec<ZoneEvent> {
135
+ let elapsed = now_ms.saturating_sub(self.started_at_ms);
136
+ let mut events = Vec::new();
137
+ for (index, phase) in self.phases.iter().enumerate() {
138
+ let (hold_start, shrink_start, shrink_end) = self.phase_bounds(index);
139
+ let stages: [(u8, u64); 3] = [(0, hold_start), (1, shrink_start), (2, shrink_end)];
140
+ for (stage, at) in stages {
141
+ if elapsed >= at && !self.announced.contains(&(index, stage)) {
142
+ self.announced.push((index, stage));
143
+ events.push(match stage {
144
+ 0 => ZoneEvent::Warning {
145
+ phase: index,
146
+ to_radius: phase.radius,
147
+ shrink_starts_at_ms: self.started_at_ms + shrink_start,
148
+ },
149
+ 1 => ZoneEvent::Shrinking { phase: index, to_radius: phase.radius },
150
+ _ => ZoneEvent::Settled { phase: index, radius: phase.radius },
151
+ });
152
+ }
153
+ }
154
+ }
155
+ if self.done(now_ms) && !self.announced.contains(&(usize::MAX, 3)) {
156
+ self.announced.push((usize::MAX, 3));
157
+ events.push(ZoneEvent::Final { radius: self.radius(now_ms) });
158
+ }
159
+ events
160
+ }
161
+ }
162
+
163
+ #[cfg(test)]
164
+ mod tests {
165
+ use super::*;
166
+
167
+ #[test]
168
+ fn shrinking_zone_interpolates_and_announces() {
169
+ let mut zone = ShrinkingZone::start(
170
+ (0.0, 0.0),
171
+ 100.0,
172
+ vec![
173
+ ShrinkPhase { radius: 50.0, hold_ms: 10_000, shrink_ms: 10_000 },
174
+ ShrinkPhase { radius: 10.0, hold_ms: 5_000, shrink_ms: 5_000 },
175
+ ],
176
+ 1_000,
177
+ );
178
+ // Hold: full radius; the warning fires immediately.
179
+ let events = zone.poll(1_000);
180
+ assert!(matches!(events[0], ZoneEvent::Warning { phase: 0, to_radius, .. } if to_radius == 50.0));
181
+ assert_eq!(zone.radius(5_000), 100.0);
182
+ // Mid-shrink: halfway between 100 and 50.
183
+ let events = zone.poll(16_000);
184
+ assert!(events.iter().any(|e| matches!(e, ZoneEvent::Shrinking { phase: 0, .. })));
185
+ assert!((zone.radius(16_000) - 75.0).abs() < 0.01);
186
+ // Settled at 50, phase 1 warning issued in the same poll window.
187
+ let events = zone.poll(21_500);
188
+ assert!(events.iter().any(|e| matches!(e, ZoneEvent::Settled { phase: 0, radius } if *radius == 50.0)));
189
+ assert!(events.iter().any(|e| matches!(e, ZoneEvent::Warning { phase: 1, .. })));
190
+ assert_eq!(zone.radius(24_000), 50.0);
191
+ // Final circle.
192
+ let events = zone.poll(31_000);
193
+ assert!(events.iter().any(|e| matches!(e, ZoneEvent::Settled { phase: 1, radius } if *radius == 10.0)));
194
+ assert!(events.iter().any(|e| matches!(e, ZoneEvent::Final { radius } if *radius == 10.0)));
195
+ assert!(zone.done(31_000));
196
+ assert!(zone.poll(40_000).is_empty(), "announce once");
197
+ // Containment tracks the live radius.
198
+ assert!(zone.contains((60.0, 0.0), 5_000));
199
+ assert!(!zone.contains((60.0, 0.0), 31_000));
200
+ }
201
+
202
+ #[test]
203
+ fn containment_and_rules() {
204
+ let set = ZoneSet {
205
+ zones: vec![
206
+ Zone { id: "spawn".into(), rule: "safe".into(), min: (-10.0, 0.0, -10.0), max: (10.0, 32.0, 10.0) },
207
+ Zone { id: "arena".into(), rule: "pvp".into(), min: (5.0, 0.0, 5.0), max: (30.0, 32.0, 30.0) },
208
+ ],
209
+ };
210
+ assert!(set.has_rule((0.0, 16.0, 0.0), "safe"));
211
+ assert!(!set.has_rule((0.0, 16.0, 0.0), "pvp"));
212
+ // Overlap region carries both rules.
213
+ assert_eq!(set.at((7.0, 16.0, 7.0)).len(), 2);
214
+ // Boundaries are inclusive; outside is outside.
215
+ assert!(set.has_rule((10.0, 32.0, 10.0), "safe"));
216
+ assert!(set.at((50.0, 16.0, 50.0)).is_empty());
217
+ }
218
+ }
@@ -47,7 +47,8 @@
47
47
  //!
48
48
  //! [`kit`] is the game kit's half here: the protocol kit hubs speak to the kit clients
49
49
  //! (snapshots and deltas with sequence numbers, retry-safe actions, refusal codes), shard keys,
50
- //! a seeded random generator, and the realtime event registry.
50
+ //! a seeded random generator, the realtime event registry, and the pieces games are made of
51
+ //! (`kit::play`, `kit::sim`, `kit::ai`, and the soft economy in `kit::econ`).
51
52
 
52
53
  mod abi;
53
54
  pub mod cron;
@@ -15,6 +15,17 @@ exports.TEMPLATES = {
15
15
  matchmaker: { dir: ['starters', 'matchmaker'], sdk: 'ckx-sdk', about: 'a rating queue that reserves sessions' },
16
16
  session: { dir: ['starters', 'session'], sdk: 'ckx-sdk', about: 'one game from lobby to result' },
17
17
  'npc-mobs': { dir: ['starters', 'npc-mobs'], sdk: 'ckx-sdk', about: "an area's mobs and NPCs" },
18
+ leaderboard: { dir: ['starters', 'leaderboard'], sdk: 'ckx-sdk', about: 'a board other hubs submit to, with seasons' },
19
+ deck: { dir: ['starters', 'deck'], sdk: 'ckx-sdk', about: 'a card table with hands only their player reads' },
20
+ tactics: { dir: ['starters', 'tactics'], sdk: 'ckx-sdk', about: "a grid battle in phases against the hub's units" },
21
+ director: { dir: ['starters', 'director'], sdk: 'ckx-sdk', about: 'waves down a lane, towers, a boss with phases' },
22
+ minigame: { dir: ['starters', 'minigame'], sdk: 'ckx-sdk', about: 'a duel of hidden throws, revealed together' },
23
+ instance: { dir: ['starters', 'instance'], sdk: 'ckx-sdk', about: "a party's private run: seeded floors, permadeath" },
24
+ arena: { dir: ['starters', 'arena'], sdk: 'ckx-sdk', about: 'classes and abilities in real time' },
25
+ racing: { dir: ['starters', 'racing'], sdk: 'ckx-sdk', about: 'races with laps and a ghost, and ball games' },
26
+ territory: { dir: ['starters', 'territory'], sdk: 'ckx-sdk', about: 'control points, and a shrinking circle' },
27
+ player: { dir: ['starters', 'player'], sdk: 'ckx-sdk', about: "a player's own hub: items, currency, crafting, quests (not a ledger)" },
28
+ market: { dir: ['starters', 'market'], sdk: 'ckx-sdk', about: 'vendors, an order book, trades between players (not a ledger)' },
18
29
  };
19
30
  const CRATE_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
20
31
  function copyTree(from, to) {