@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,370 @@
1
+ //! Realtime ability casts: cooldown/resource/range validation books,
2
+ //! server-stepped projectiles with tolerance windows, and AoE resolution
3
+ //! with falloff. Pure state machines — the hub feeds live
4
+ //! actor positions in and applies the resulting damage itself (see
5
+ //! [`super::combat`]). All stepping is bounded (max lifetime / max range) so fuel
6
+ //! stays predictable at a hub's tick rate.
7
+
8
+ use serde::{Deserialize, Serialize};
9
+ use std::collections::BTreeMap;
10
+
11
+ /// What a cast does once validated.
12
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
13
+ pub enum AbilityKind {
14
+ /// Immediate hit on a target in range (hitscan).
15
+ Instant { damage: i64 },
16
+ /// A stepped projectile along a direction; first actor within
17
+ /// `hit_radius` takes the damage.
18
+ Projectile { speed: f32, damage: i64, max_range: f32, hit_radius: f32 },
19
+ /// A delayed explosion at a target point; damage within `radius`,
20
+ /// optionally scaled down linearly with distance.
21
+ Aoe { radius: f32, damage: i64, delay_ms: u64, falloff: bool },
22
+ }
23
+
24
+ /// One ability's rules (usually from the hub's config).
25
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
26
+ pub struct AbilitySpec {
27
+ pub ability_id: String,
28
+ pub cooldown_ms: u64,
29
+ pub resource_cost: i64,
30
+ /// Max cast distance (target point / initial direction anchor).
31
+ pub range: f32,
32
+ pub kind: AbilityKind,
33
+ }
34
+
35
+ /// Per-caster server-held book: cooldown stamps + a regenerating resource
36
+ /// pool. One book per user, kept in the hub's state.
37
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
38
+ pub struct CasterBook {
39
+ /// ability id -> earliest next cast (ms).
40
+ pub ready_at: BTreeMap<String, u64>,
41
+ pub resource: i64,
42
+ pub resource_max: i64,
43
+ /// Resource points regenerated per second.
44
+ pub regen_per_sec: i64,
45
+ pub last_regen_ms: u64,
46
+ }
47
+
48
+ impl Default for CasterBook {
49
+ fn default() -> Self {
50
+ Self { ready_at: BTreeMap::new(), resource: 100, resource_max: 100, regen_per_sec: 5, last_regen_ms: 0 }
51
+ }
52
+ }
53
+
54
+ impl CasterBook {
55
+ /// Apply resource regeneration up to `now_ms`.
56
+ pub fn regen(&mut self, now_ms: u64) {
57
+ if self.last_regen_ms == 0 {
58
+ self.last_regen_ms = now_ms;
59
+ return;
60
+ }
61
+ let elapsed = now_ms.saturating_sub(self.last_regen_ms);
62
+ let gained = (elapsed as i64 * self.regen_per_sec) / 1000;
63
+ if gained > 0 {
64
+ self.resource = (self.resource + gained).min(self.resource_max);
65
+ // Keep the remainder window so slow regen still accrues.
66
+ self.last_regen_ms += (gained * 1000 / self.regen_per_sec.max(1)) as u64;
67
+ }
68
+ }
69
+ }
70
+
71
+ fn dist(a: (f32, f32), b: (f32, f32)) -> f32 {
72
+ let (dx, dz) = (a.0 - b.0, a.1 - b.1);
73
+ (dx * dx + dz * dz).sqrt()
74
+ }
75
+
76
+ /// A validated cast, ready for the hub to act on.
77
+ #[derive(Debug, Clone, PartialEq)]
78
+ pub struct ValidCast {
79
+ pub ability_id: String,
80
+ /// Normalized aim direction (projectiles).
81
+ pub dir: (f32, f32),
82
+ /// Clamped target point (instant/AoE).
83
+ pub target: (f32, f32),
84
+ }
85
+
86
+ /// Validate a cast against the book: cooldown, resource, range. On success
87
+ /// the cooldown is armed and the resource deducted (the cast is committed).
88
+ pub fn validate_cast(
89
+ spec: &AbilitySpec,
90
+ book: &mut CasterBook,
91
+ caster_pos: (f32, f32),
92
+ target: (f32, f32),
93
+ now_ms: u64,
94
+ ) -> Result<ValidCast, &'static str> {
95
+ book.regen(now_ms);
96
+ if book.ready_at.get(&spec.ability_id).copied().unwrap_or(0) > now_ms {
97
+ return Err("ability on cooldown");
98
+ }
99
+ if book.resource < spec.resource_cost {
100
+ return Err("not enough resource");
101
+ }
102
+ let to_target = dist(caster_pos, target);
103
+ if to_target > spec.range && !matches!(spec.kind, AbilityKind::Projectile { .. }) {
104
+ return Err("target out of range");
105
+ }
106
+ // Aim direction: toward the target; degenerate aim defaults to +x.
107
+ let dir = if to_target > 1e-3 { ((target.0 - caster_pos.0) / to_target, (target.1 - caster_pos.1) / to_target) } else { (1.0, 0.0) };
108
+ book.ready_at.insert(spec.ability_id.clone(), now_ms + spec.cooldown_ms);
109
+ book.resource -= spec.resource_cost;
110
+ Ok(ValidCast { ability_id: spec.ability_id.clone(), dir, target })
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Projectiles
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /// A live projectile the hub steps each tick.
118
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
119
+ pub struct Projectile {
120
+ pub ability_id: String,
121
+ pub caster_id: String,
122
+ pub x: f32,
123
+ pub z: f32,
124
+ pub dir: (f32, f32),
125
+ pub speed: f32,
126
+ pub damage: i64,
127
+ pub hit_radius: f32,
128
+ pub traveled: f32,
129
+ pub max_range: f32,
130
+ pub launched_at_ms: u64,
131
+ }
132
+
133
+ /// The outcome of one projectile step.
134
+ #[derive(Debug, Clone, PartialEq)]
135
+ pub enum ProjectileStep {
136
+ /// Still flying (new position recorded on the projectile).
137
+ Flying,
138
+ /// Hit `target_id` at the recorded position; despawn.
139
+ Hit { target_id: String },
140
+ /// Ran out of range or was blocked; despawn.
141
+ Expired,
142
+ }
143
+
144
+ impl Projectile {
145
+ pub fn launch(spec: &AbilitySpec, caster_id: &str, from: (f32, f32), cast: &ValidCast, now_ms: u64) -> Option<Self> {
146
+ let AbilityKind::Projectile { speed, damage, max_range, hit_radius } = spec.kind else {
147
+ return None;
148
+ };
149
+ Some(Self {
150
+ ability_id: spec.ability_id.clone(),
151
+ caster_id: caster_id.to_string(),
152
+ x: from.0,
153
+ z: from.1,
154
+ dir: cast.dir,
155
+ speed,
156
+ damage,
157
+ hit_radius,
158
+ traveled: 0.0,
159
+ max_range,
160
+ launched_at_ms: now_ms,
161
+ })
162
+ }
163
+
164
+ /// Advance `dt_ms`, sub-stepping so tick-rate simulation cannot tunnel
165
+ /// through targets (tolerance window = hit_radius per sub-step).
166
+ /// `hit_test(x, z, radius)` returns a hit target (never the caster) or
167
+ /// None; `blocked(x, z)` marks walls.
168
+ pub fn step(
169
+ &mut self,
170
+ dt_ms: u64,
171
+ hit_test: &mut impl FnMut(f32, f32, f32) -> Option<String>,
172
+ blocked: &mut impl FnMut(f32, f32) -> bool,
173
+ ) -> ProjectileStep {
174
+ let total = self.speed * dt_ms as f32 / 1000.0;
175
+ let sub = self.hit_radius.max(0.25);
176
+ let mut moved = 0.0f32;
177
+ while moved < total {
178
+ let advance = sub.min(total - moved);
179
+ self.x += self.dir.0 * advance;
180
+ self.z += self.dir.1 * advance;
181
+ self.traveled += advance;
182
+ moved += advance;
183
+ if blocked(self.x, self.z) {
184
+ return ProjectileStep::Expired;
185
+ }
186
+ if let Some(target_id) = hit_test(self.x, self.z, self.hit_radius) {
187
+ return ProjectileStep::Hit { target_id };
188
+ }
189
+ if self.traveled >= self.max_range {
190
+ return ProjectileStep::Expired;
191
+ }
192
+ }
193
+ ProjectileStep::Flying
194
+ }
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // AoE
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /// A delayed area effect awaiting detonation.
202
+ #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
203
+ pub struct PendingAoe {
204
+ pub ability_id: String,
205
+ pub caster_id: String,
206
+ pub x: f32,
207
+ pub z: f32,
208
+ pub radius: f32,
209
+ pub damage: i64,
210
+ pub falloff: bool,
211
+ pub detonate_at_ms: u64,
212
+ }
213
+
214
+ impl PendingAoe {
215
+ pub fn from_cast(spec: &AbilitySpec, caster_id: &str, cast: &ValidCast, now_ms: u64) -> Option<Self> {
216
+ let AbilityKind::Aoe { radius, damage, delay_ms, falloff } = spec.kind else {
217
+ return None;
218
+ };
219
+ Some(Self {
220
+ ability_id: spec.ability_id.clone(),
221
+ caster_id: caster_id.to_string(),
222
+ x: cast.target.0,
223
+ z: cast.target.1,
224
+ radius,
225
+ damage,
226
+ falloff,
227
+ detonate_at_ms: now_ms + delay_ms,
228
+ })
229
+ }
230
+
231
+ pub fn ready(&self, now_ms: u64) -> bool {
232
+ now_ms >= self.detonate_at_ms
233
+ }
234
+
235
+ /// Damage per victim inside the radius (linear falloff to 25% at the
236
+ /// edge when enabled). Victims = (id, position); the caster is exempt.
237
+ pub fn resolve(&self, victims: &[(String, (f32, f32))]) -> Vec<(String, i64)> {
238
+ victims
239
+ .iter()
240
+ .filter(|(id, _)| *id != self.caster_id)
241
+ .filter_map(|(id, pos)| {
242
+ let d = dist((self.x, self.z), *pos);
243
+ if d > self.radius {
244
+ return None;
245
+ }
246
+ let damage = if self.falloff && self.radius > 0.0 {
247
+ let scale = 1.0 - 0.75 * (d / self.radius);
248
+ ((self.damage as f32) * scale).round() as i64
249
+ } else {
250
+ self.damage
251
+ };
252
+ Some((id.clone(), damage.max(1)))
253
+ })
254
+ .collect()
255
+ }
256
+ }
257
+
258
+ #[cfg(test)]
259
+ mod tests {
260
+ use super::*;
261
+
262
+ fn bolt() -> AbilitySpec {
263
+ AbilitySpec {
264
+ ability_id: "bolt".into(),
265
+ cooldown_ms: 1_000,
266
+ resource_cost: 10,
267
+ range: 20.0,
268
+ kind: AbilityKind::Projectile { speed: 10.0, damage: 5, max_range: 12.0, hit_radius: 0.5 },
269
+ }
270
+ }
271
+
272
+ fn nova() -> AbilitySpec {
273
+ AbilitySpec {
274
+ ability_id: "nova".into(),
275
+ cooldown_ms: 5_000,
276
+ resource_cost: 30,
277
+ range: 8.0,
278
+ kind: AbilityKind::Aoe { radius: 4.0, damage: 20, delay_ms: 500, falloff: true },
279
+ }
280
+ }
281
+
282
+ #[test]
283
+ fn cast_validation_cooldown_resource_range() {
284
+ let spec = nova();
285
+ let mut book = CasterBook::default();
286
+ // Out of range for a targeted AoE.
287
+ assert_eq!(validate_cast(&spec, &mut book, (0.0, 0.0), (20.0, 0.0), 1_000).unwrap_err(), "target out of range");
288
+ let cast = validate_cast(&spec, &mut book, (0.0, 0.0), (5.0, 0.0), 1_000).unwrap();
289
+ assert_eq!(cast.target, (5.0, 0.0));
290
+ assert_eq!(book.resource, 70);
291
+ // Cooldown blocks an immediate re-cast.
292
+ assert_eq!(validate_cast(&spec, &mut book, (0.0, 0.0), (5.0, 0.0), 1_100).unwrap_err(), "ability on cooldown");
293
+ // After cooldown, resource gate: drain (regen off) then fail.
294
+ book.resource = 5;
295
+ book.regen_per_sec = 0;
296
+ assert_eq!(validate_cast(&spec, &mut book, (0.0, 0.0), (5.0, 0.0), 7_000).unwrap_err(), "not enough resource");
297
+ }
298
+
299
+ #[test]
300
+ fn resource_regenerates_over_time() {
301
+ let mut book = CasterBook { resource: 0, last_regen_ms: 1_000, ..Default::default() };
302
+ book.regen(3_000); // 2s at 5/s
303
+ assert_eq!(book.resource, 10);
304
+ book.regen(3_000);
305
+ assert_eq!(book.resource, 10, "no double counting");
306
+ book.regen(100_000);
307
+ assert_eq!(book.resource, book.resource_max, "clamped at max");
308
+ }
309
+
310
+ #[test]
311
+ fn projectile_flies_hits_and_expires() {
312
+ let spec = bolt();
313
+ let mut book = CasterBook::default();
314
+ let cast = validate_cast(&spec, &mut book, (0.0, 0.0), (10.0, 0.0), 0).unwrap();
315
+ let mut projectile = Projectile::launch(&spec, "caster", (0.0, 0.0), &cast, 0).unwrap();
316
+
317
+ // A target at x=5 gets hit during the first second of flight.
318
+ let mut hit = |x: f32, z: f32, r: f32| (dist((x, z), (5.0, 0.0)) <= r).then(|| "victim".to_string());
319
+ let mut open = |_x: f32, _z: f32| false;
320
+ let step = projectile.step(1_000, &mut hit, &mut open);
321
+ assert_eq!(step, ProjectileStep::Hit { target_id: "victim".into() });
322
+ assert!(projectile.traveled <= 5.5, "stops at the hit, no overshoot");
323
+
324
+ // No targets: expires at max_range (12 units at 10 u/s).
325
+ let cast = validate_cast(&spec, &mut book, (0.0, 0.0), (10.0, 0.0), 5_000).unwrap();
326
+ let mut projectile = Projectile::launch(&spec, "caster", (0.0, 0.0), &cast, 5_000).unwrap();
327
+ let mut none = |_x: f32, _z: f32, _r: f32| None;
328
+ assert_eq!(projectile.step(1_000, &mut none, &mut open), ProjectileStep::Flying);
329
+ assert_eq!(projectile.step(1_000, &mut none, &mut open), ProjectileStep::Expired);
330
+
331
+ // Walls block flight.
332
+ let cast = validate_cast(&spec, &mut book, (0.0, 0.0), (10.0, 0.0), 20_000).unwrap();
333
+ let mut projectile = Projectile::launch(&spec, "caster", (0.0, 0.0), &cast, 20_000).unwrap();
334
+ let mut wall = |x: f32, _z: f32| x >= 2.0;
335
+ assert_eq!(projectile.step(1_000, &mut none, &mut wall), ProjectileStep::Expired);
336
+ assert!(projectile.x <= 2.5);
337
+ }
338
+
339
+ #[test]
340
+ fn aoe_delays_resolves_with_falloff_and_exempts_caster() {
341
+ let spec = nova();
342
+ let mut book = CasterBook::default();
343
+ let cast = validate_cast(&spec, &mut book, (0.0, 0.0), (5.0, 0.0), 1_000).unwrap();
344
+ let aoe = PendingAoe::from_cast(&spec, "caster", &cast, 1_000).unwrap();
345
+ assert!(!aoe.ready(1_400));
346
+ assert!(aoe.ready(1_500));
347
+ let victims = vec![
348
+ ("caster".to_string(), (5.0, 0.0)), // exempt
349
+ ("center".to_string(), (5.0, 0.0)), // full damage
350
+ ("edge".to_string(), (9.0, 0.0)), // r=4 away -> 25%
351
+ ("outside".to_string(), (10.5, 0.0)), // out
352
+ ];
353
+ let hits = aoe.resolve(&victims);
354
+ assert_eq!(hits.len(), 2);
355
+ assert_eq!(hits[0], ("center".to_string(), 20));
356
+ assert_eq!(hits[1], ("edge".to_string(), 5));
357
+ }
358
+
359
+ #[test]
360
+ fn serde_roundtrip() {
361
+ let spec = bolt();
362
+ let mut book = CasterBook::default();
363
+ let cast = validate_cast(&spec, &mut book, (0.0, 0.0), (3.0, 4.0), 0).unwrap();
364
+ let projectile = Projectile::launch(&spec, "c", (0.0, 0.0), &cast, 0).unwrap();
365
+ let json = serde_json::to_string(&(&book, &projectile)).unwrap();
366
+ let (book2, projectile2): (CasterBook, Projectile) = serde_json::from_str(&json).unwrap();
367
+ assert_eq!(book2.resource, book.resource);
368
+ assert_eq!(projectile2.dir, projectile.dir);
369
+ }
370
+ }
@@ -0,0 +1,220 @@
1
+ //! Deck/hand/zone logic with true hidden information: hands and deck order
2
+ //! live in the HUB'S STATE (secrets the hub holds — clients only ever see their
3
+ //! own hand through a call the hub answers for its caller), while public zones are published
4
+ //! on its topics. Shuffles are seeded (`kit::Rng`)
5
+ //! so instance replays are deterministic.
6
+
7
+ use crate::kit::Rng;
8
+ use serde::{Deserialize, Serialize};
9
+ use serde_json::{Value, json};
10
+ use std::collections::BTreeMap;
11
+
12
+ pub type CardId = String;
13
+
14
+ /// A pile with hidden order (draw deck / discard).
15
+ #[derive(Serialize, Deserialize, Clone, Debug, Default)]
16
+ pub struct Pile {
17
+ /// Top of the pile is the END of the vec (pop to draw).
18
+ pub cards: Vec<CardId>,
19
+ }
20
+
21
+ impl Pile {
22
+ pub fn len(&self) -> usize {
23
+ self.cards.len()
24
+ }
25
+
26
+ pub fn is_empty(&self) -> bool {
27
+ self.cards.is_empty()
28
+ }
29
+
30
+ pub fn draw(&mut self) -> Option<CardId> {
31
+ self.cards.pop()
32
+ }
33
+
34
+ pub fn put(&mut self, card: CardId) {
35
+ self.cards.push(card);
36
+ }
37
+
38
+ /// Seeded Fisher-Yates shuffle.
39
+ pub fn shuffle(&mut self, rng: &mut Rng) {
40
+ let n = self.cards.len();
41
+ for i in (1..n).rev() {
42
+ let j = (rng.next_u64() % (i as u64 + 1)) as usize;
43
+ self.cards.swap(i, j);
44
+ }
45
+ }
46
+ }
47
+
48
+ /// The full table: draw pile, discard, per-actor hidden hands, and named
49
+ /// public zones. Everything serde — one `CardTable` per match partition.
50
+ #[derive(Serialize, Deserialize, Clone, Debug, Default)]
51
+ pub struct CardTable {
52
+ pub draw: Pile,
53
+ pub discard: Pile,
54
+ pub hands: BTreeMap<String, Vec<CardId>>,
55
+ /// Named public zones (battlefield, trick, melds, ...), in play order.
56
+ pub zones: BTreeMap<String, Vec<(String, CardId)>>,
57
+ pub seed: u64,
58
+ }
59
+
60
+ impl CardTable {
61
+ /// Build a table from a card catalog and shuffle with the given seed.
62
+ pub fn new(catalog: impl IntoIterator<Item = CardId>, seed: u64) -> Self {
63
+ let mut table = Self { draw: Pile { cards: catalog.into_iter().collect() }, seed, ..Default::default() };
64
+ let mut rng = Rng::seeded(seed);
65
+ table.draw.shuffle(&mut rng);
66
+ table
67
+ }
68
+
69
+ /// Deal `count` cards to each actor (round-robin, deterministic order).
70
+ pub fn deal(&mut self, actors: &[String], count: usize) {
71
+ for _ in 0..count {
72
+ for actor in actors {
73
+ if let Some(card) = self.draw.draw() {
74
+ self.hands.entry(actor.clone()).or_default().push(card);
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ /// Draw one card into an actor's hidden hand. Recycles the discard
81
+ /// (reshuffled with the table seed + draw count) when the deck empties.
82
+ pub fn draw_to_hand(&mut self, actor_id: &str) -> Option<CardId> {
83
+ if self.draw.is_empty() && !self.discard.is_empty() {
84
+ std::mem::swap(&mut self.draw.cards, &mut self.discard.cards);
85
+ let mut rng = Rng::seeded(self.seed ^ (self.draw.len() as u64) << 8);
86
+ self.draw.shuffle(&mut rng);
87
+ }
88
+ let card = self.draw.draw()?;
89
+ self.hands.entry(actor_id.to_string()).or_default().push(card.clone());
90
+ Some(card)
91
+ }
92
+
93
+ /// The caller's own hand (the ONLY read path for hidden cards).
94
+ pub fn hand(&self, actor_id: &str) -> &[CardId] {
95
+ self.hands.get(actor_id).map(Vec::as_slice).unwrap_or(&[])
96
+ }
97
+
98
+ pub fn hand_sizes(&self) -> BTreeMap<String, usize> {
99
+ self.hands.iter().map(|(id, hand)| (id.clone(), hand.len())).collect()
100
+ }
101
+
102
+ /// Play (reveal) a card from a hand into a public zone. Fails when the
103
+ /// caller does not hold the card — the legality floor every game gets.
104
+ pub fn play(&mut self, actor_id: &str, card: &str, zone: &str) -> Result<(), &'static str> {
105
+ let hand = self.hands.entry(actor_id.to_string()).or_default();
106
+ let index = hand.iter().position(|c| c == card).ok_or("card not in hand")?;
107
+ let card = hand.remove(index);
108
+ self.zones.entry(zone.to_string()).or_default().push((actor_id.to_string(), card));
109
+ Ok(())
110
+ }
111
+
112
+ /// Move every card in a zone to the discard (trick taken, turn over).
113
+ pub fn clear_zone(&mut self, zone: &str) -> Vec<(String, CardId)> {
114
+ let cards = self.zones.remove(zone).unwrap_or_default();
115
+ for (_, card) in &cards {
116
+ self.discard.put(card.clone());
117
+ }
118
+ cards
119
+ }
120
+
121
+ /// The public view (zones + counts, NEVER hidden cards) for model sync
122
+ /// and spectator events.
123
+ pub fn public_json(&self) -> Value {
124
+ json!({
125
+ "drawCount": self.draw.len(),
126
+ "discardCount": self.discard.len(),
127
+ "handSizes": self.hand_sizes(),
128
+ "zones": self.zones.iter().map(|(name, cards)| {
129
+ (name.clone(), cards.iter().map(|(actor, card)| json!({
130
+ "actorId": actor, "card": card,
131
+ })).collect::<Vec<_>>())
132
+ }).collect::<BTreeMap<_, _>>(),
133
+ })
134
+ }
135
+ }
136
+
137
+ /// Build a standard 52-card catalog ("AS", "2S", ..., "KC") — handy for
138
+ /// examples and tests; a real game builds its own from its hub's config.
139
+ pub fn standard_52() -> Vec<CardId> {
140
+ let ranks = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
141
+ let suits = ["S", "H", "D", "C"];
142
+ let mut cards = Vec::with_capacity(52);
143
+ for suit in suits {
144
+ for rank in ranks {
145
+ cards.push(format!("{rank}{suit}"));
146
+ }
147
+ }
148
+ cards
149
+ }
150
+
151
+ #[cfg(test)]
152
+ mod tests {
153
+ use super::*;
154
+
155
+ #[test]
156
+ fn seeded_shuffles_are_deterministic_and_seed_sensitive() {
157
+ let a = CardTable::new(standard_52(), 42);
158
+ let b = CardTable::new(standard_52(), 42);
159
+ let c = CardTable::new(standard_52(), 43);
160
+ assert_eq!(a.draw.cards, b.draw.cards);
161
+ assert_ne!(a.draw.cards, c.draw.cards);
162
+ // Shuffle is a permutation.
163
+ let mut sorted = a.draw.cards.clone();
164
+ sorted.sort();
165
+ let mut catalog = standard_52();
166
+ catalog.sort();
167
+ assert_eq!(sorted, catalog);
168
+ }
169
+
170
+ #[test]
171
+ fn deal_draw_play_lifecycle() {
172
+ let actors = vec!["a".to_string(), "b".to_string()];
173
+ let mut table = CardTable::new(standard_52(), 7);
174
+ table.deal(&actors, 5);
175
+ assert_eq!(table.hand("a").len(), 5);
176
+ assert_eq!(table.hand("b").len(), 5);
177
+ assert_eq!(table.draw.len(), 42);
178
+
179
+ let drawn = table.draw_to_hand("a").unwrap();
180
+ assert!(table.hand("a").contains(&drawn));
181
+
182
+ let card = table.hand("a")[0].clone();
183
+ table.play("a", &card, "trick").unwrap();
184
+ assert_eq!(table.hand("a").len(), 5);
185
+ assert_eq!(table.zones["trick"].len(), 1);
186
+ assert_eq!(table.play("a", "not-held", "trick").unwrap_err(), "card not in hand");
187
+ // b cannot play a's cards.
188
+ let b_err = table.play("b", &card, "trick");
189
+ assert!(b_err.is_err());
190
+
191
+ let taken = table.clear_zone("trick");
192
+ assert_eq!(taken.len(), 1);
193
+ assert_eq!(table.discard.len(), 1);
194
+ }
195
+
196
+ #[test]
197
+ fn discard_recycles_into_draw() {
198
+ let mut table = CardTable::new(vec!["x".into(), "y".into()], 1);
199
+ table.draw_to_hand("a");
200
+ table.draw_to_hand("a");
201
+ assert!(table.draw.is_empty());
202
+ assert!(table.draw_to_hand("a").is_none(), "nothing anywhere");
203
+ // Discard a card, then drawing recycles it.
204
+ let card = table.hand("a")[0].clone();
205
+ table.play("a", &card, "z").unwrap();
206
+ table.clear_zone("z");
207
+ let recycled = table.draw_to_hand("b").unwrap();
208
+ assert_eq!(recycled, card);
209
+ }
210
+
211
+ #[test]
212
+ fn public_view_hides_hands() {
213
+ let mut table = CardTable::new(standard_52(), 9);
214
+ table.deal(&["a".to_string()], 3);
215
+ let view = table.public_json();
216
+ assert_eq!(view["handSizes"]["a"], 3);
217
+ assert!(view.get("hands").is_none(), "hidden hands never serialize publicly");
218
+ assert_eq!(view["drawCount"], 49);
219
+ }
220
+ }