@crowdedkingdoms/ckx-kit 0.2.0 → 0.4.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/crates/ckx-sdk/Cargo.toml +1 -1
- package/crates/ckx-sdk/src/kit/ai/brain.rs +288 -0
- package/crates/ckx-sdk/src/kit/ai/flow.rs +163 -0
- package/crates/ckx-sdk/src/kit/ai/mod.rs +10 -0
- package/crates/ckx-sdk/src/kit/ai/path.rs +214 -0
- package/crates/ckx-sdk/src/kit/ai/steering.rs +137 -0
- package/crates/ckx-sdk/src/kit/ai/turn.rs +141 -0
- package/crates/ckx-sdk/src/kit/mod.rs +542 -0
- package/crates/ckx-sdk/src/kit/play/abilities.rs +370 -0
- package/crates/ckx-sdk/src/kit/play/cards.rs +220 -0
- package/crates/ckx-sdk/src/kit/play/combat.rs +292 -0
- package/crates/ckx-sdk/src/kit/play/mod.rs +15 -0
- package/crates/ckx-sdk/src/kit/play/movement.rs +196 -0
- package/crates/ckx-sdk/src/kit/play/score.rs +183 -0
- package/crates/ckx-sdk/src/kit/play/timing.rs +218 -0
- package/crates/ckx-sdk/src/kit/play/turns.rs +315 -0
- package/crates/ckx-sdk/src/kit/presence.rs +142 -0
- package/crates/ckx-sdk/src/kit/rng.rs +137 -0
- package/crates/ckx-sdk/src/kit/shard.rs +65 -0
- package/crates/ckx-sdk/src/kit/sim/daycycle.rs +71 -0
- package/crates/ckx-sdk/src/kit/sim/mod.rs +10 -0
- package/crates/ckx-sdk/src/kit/sim/nodes.rs +109 -0
- package/crates/ckx-sdk/src/kit/sim/waves.rs +105 -0
- package/crates/ckx-sdk/src/kit/sim/weather.rs +142 -0
- package/crates/ckx-sdk/src/kit/sim/zones.rs +218 -0
- package/crates/ckx-sdk/src/kit/wire.rs +266 -0
- package/crates/ckx-sdk/src/lib.rs +13 -0
- package/crates/ckx-sdk/src/model.rs +5 -0
- package/crates/crowdy-client-sdk/Cargo.toml +1 -1
- package/dist/cli/src/new.js +9 -0
- package/kit.pin +4 -4
- package/package.json +1 -1
- package/starters/arena/Cargo.toml +14 -0
- package/starters/arena/src/lib.rs +849 -0
- package/starters/ckx.json +12 -1
- package/starters/deck/Cargo.toml +14 -0
- package/starters/deck/src/lib.rs +510 -0
- package/starters/director/Cargo.toml +14 -0
- package/starters/director/src/lib.rs +628 -0
- package/starters/grid-mod/Cargo.toml +1 -1
- package/starters/instance/Cargo.toml +14 -0
- package/starters/instance/src/lib.rs +656 -0
- package/starters/leaderboard/Cargo.toml +14 -0
- package/starters/leaderboard/src/lib.rs +395 -0
- package/starters/matchmaker/Cargo.toml +1 -1
- package/starters/matchmaker/src/lib.rs +334 -74
- package/starters/minigame/Cargo.toml +14 -0
- package/starters/minigame/src/lib.rs +391 -0
- package/starters/npc-mobs/Cargo.toml +1 -1
- package/starters/npc-mobs/src/lib.rs +277 -67
- package/starters/racing/Cargo.toml +14 -0
- package/starters/racing/src/lib.rs +1151 -0
- package/starters/session/Cargo.toml +1 -1
- package/starters/session/src/lib.rs +191 -86
- package/starters/tactics/Cargo.toml +14 -0
- package/starters/tactics/src/lib.rs +562 -0
- package/starters/territory/Cargo.toml +14 -0
- package/starters/territory/src/lib.rs +878 -0
- package/starters/world-tick/Cargo.toml +1 -1
- package/starters/world-tick/src/lib.rs +151 -38
package/starters/ckx.json
CHANGED
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
"world": { "kind": "hub", "crate": "world-tick", "wasm": "world_tick.wasm", "client": true, "persist_every_ms": 10000 },
|
|
5
5
|
"matchmaker": { "kind": "hub", "parent": "world", "crate": "matchmaker", "wasm": "matchmaker.wasm", "client": true, "calls": ["session"] },
|
|
6
6
|
"session": { "kind": "hub", "parent": "matchmaker", "crate": "session", "wasm": "session.wasm", "client": true },
|
|
7
|
-
"npcs": { "kind": "hub", "parent": "world", "crate": "npc-mobs", "wasm": "npc_mobs.wasm", "client": true, "calls": ["world"], "scopes": ["world.read"] }
|
|
7
|
+
"npcs": { "kind": "hub", "parent": "world", "crate": "npc-mobs", "wasm": "npc_mobs.wasm", "client": true, "calls": ["world"], "scopes": ["world.read"] },
|
|
8
|
+
"leaderboard": { "kind": "hub", "parent": "world", "crate": "leaderboard", "wasm": "leaderboard.wasm", "client": true },
|
|
9
|
+
"deck": { "kind": "hub", "parent": "world", "crate": "deck", "wasm": "deck.wasm", "client": true },
|
|
10
|
+
"tactics": { "kind": "hub", "parent": "world", "crate": "tactics", "wasm": "tactics.wasm", "client": true },
|
|
11
|
+
"director": { "kind": "hub", "parent": "world", "crate": "director", "wasm": "director.wasm", "client": true },
|
|
12
|
+
"minigame": { "kind": "hub", "parent": "world", "crate": "minigame", "wasm": "minigame.wasm", "client": true },
|
|
13
|
+
"instance": { "kind": "hub", "parent": "world", "crate": "instance", "wasm": "instance.wasm", "client": true, "calls": ["leaderboard"] },
|
|
14
|
+
"arena": { "kind": "hub", "parent": "world", "crate": "arena", "wasm": "arena.wasm", "client": true },
|
|
15
|
+
"race": { "kind": "hub", "parent": "world", "crate": "racing", "wasm": "racing.wasm", "client": true, "calls": ["leaderboard"] },
|
|
16
|
+
"ball": { "kind": "hub", "parent": "world", "crate": "racing", "wasm": "racing.wasm", "client": true },
|
|
17
|
+
"territory": { "kind": "hub", "parent": "world", "crate": "territory", "wasm": "territory.wasm", "client": true, "scopes": ["world.read"] },
|
|
18
|
+
"zone": { "kind": "hub", "parent": "world", "crate": "territory", "wasm": "territory.wasm", "client": true, "scopes": ["world.read"] }
|
|
8
19
|
}
|
|
9
20
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "deck"
|
|
3
|
+
description = "Starter: a card table with hidden hands the hub holds, turns with a time limit, and crazy eights as its game"
|
|
4
|
+
version = "0.1.0"
|
|
5
|
+
edition = "2024"
|
|
6
|
+
publish = false
|
|
7
|
+
|
|
8
|
+
[lib]
|
|
9
|
+
crate-type = ["cdylib"]
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
ckx-sdk = "0.11.0"
|
|
13
|
+
serde = { version = "1", features = ["derive"] }
|
|
14
|
+
serde_json = "1"
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
//! A card table, keyed by its id, with true hidden information: the deck's order and every
|
|
2
|
+
//! hand live in the hub's state, and a player reads only their own hand, with a call the hub
|
|
3
|
+
//! answers for its caller. The starter's game is crazy eights: match the top card's suit or
|
|
4
|
+
//! rank, eights are wild and name a suit, draw when you cannot (once a turn), and the first to
|
|
5
|
+
//! empty their hand wins the points left in the others'. Replace [`playable`] and
|
|
6
|
+
//! [`card_points`] with your game's rules.
|
|
7
|
+
//!
|
|
8
|
+
//! Endpoints (players): `join`, `leave`, `start` (the host, from two players), `play { card,
|
|
9
|
+
//! suit? }`, `draw`, `pass` (after drawing), `hand` (the caller's own), `state` (anyone). It
|
|
10
|
+
//! speaks the kit protocol (`ckx_sdk::kit`): the `table` topic carries the public table as
|
|
11
|
+
//! `snapshot` messages and a `changed` message naming the players whose hand changed
|
|
12
|
+
//! (`what`: `hand`), who read it with `hand`; `snapshot { topic: "table" }` answers the table;
|
|
13
|
+
//! changes may carry an `action_id`; refusals carry `kit:<code>:` codes.
|
|
14
|
+
|
|
15
|
+
use std::collections::BTreeMap;
|
|
16
|
+
|
|
17
|
+
use ckx_sdk::kit::play::cards::{CardId, CardTable, standard_52};
|
|
18
|
+
use ckx_sdk::kit::{self, Publisher, Receipts, Rng, codes};
|
|
19
|
+
use ckx_sdk::prelude::*;
|
|
20
|
+
|
|
21
|
+
pub const TOPIC: &str = "table";
|
|
22
|
+
pub const MIN_PLAYERS: usize = 2;
|
|
23
|
+
pub const MAX_PLAYERS: usize = 6;
|
|
24
|
+
const HAND: usize = 5;
|
|
25
|
+
const TURN_MS: u64 = 30_000;
|
|
26
|
+
|
|
27
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
28
|
+
#[serde(rename_all = "snake_case")]
|
|
29
|
+
pub enum Phase {
|
|
30
|
+
#[default]
|
|
31
|
+
Lobby,
|
|
32
|
+
Playing,
|
|
33
|
+
Finished,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// A card's rank and suit: `"10H"` is rank `10`, suit `H`.
|
|
37
|
+
pub fn split(card: &str) -> (&str, &str) {
|
|
38
|
+
card.split_at(card.len().saturating_sub(1))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Whether `card` may go on `top` with `suit` the suit in play. The starter's rule: same suit,
|
|
42
|
+
/// same rank, or an eight.
|
|
43
|
+
pub fn playable(card: &str, top: &str, suit: &str) -> bool {
|
|
44
|
+
let (rank, s) = split(card);
|
|
45
|
+
rank == "8" || s == suit || rank == split(top).0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// What a card left in a hand is worth to the winner: eights 50, faces 10, aces 1.
|
|
49
|
+
pub fn card_points(card: &str) -> i64 {
|
|
50
|
+
match split(card).0 {
|
|
51
|
+
"8" => 50,
|
|
52
|
+
"J" | "Q" | "K" | "10" => 10,
|
|
53
|
+
"A" => 1,
|
|
54
|
+
n => n.parse().unwrap_or(0),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// A move, as the table shows it.
|
|
59
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
60
|
+
pub struct Played {
|
|
61
|
+
pub player: u64,
|
|
62
|
+
/// The card, or none for a draw or a pass.
|
|
63
|
+
pub card: Option<CardId>,
|
|
64
|
+
pub action: String,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// The table as everyone sees it: never a hidden card.
|
|
68
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
69
|
+
pub struct TableView {
|
|
70
|
+
pub phase: Phase,
|
|
71
|
+
pub players: Vec<u64>,
|
|
72
|
+
pub host: Option<u64>,
|
|
73
|
+
/// Cards in each player's hand, by player id.
|
|
74
|
+
pub hand_counts: BTreeMap<u64, usize>,
|
|
75
|
+
pub top: Option<CardId>,
|
|
76
|
+
/// The suit in play (an eight names one).
|
|
77
|
+
pub suit: Option<String>,
|
|
78
|
+
pub draw_count: usize,
|
|
79
|
+
pub turn: Option<u64>,
|
|
80
|
+
pub turn_ends: Option<u64>,
|
|
81
|
+
pub last: Option<Played>,
|
|
82
|
+
pub winner: Option<u64>,
|
|
83
|
+
pub scores: BTreeMap<u64, i64>,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// What `hand` answers its caller.
|
|
87
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
88
|
+
pub struct HandView {
|
|
89
|
+
pub cards: Vec<CardId>,
|
|
90
|
+
/// The cards that may go on the table now (empty when it is not the caller's turn).
|
|
91
|
+
pub playable: Vec<CardId>,
|
|
92
|
+
pub drawn: bool,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#[derive(Deserialize)]
|
|
96
|
+
struct Play {
|
|
97
|
+
card: CardId,
|
|
98
|
+
#[serde(default)]
|
|
99
|
+
suit: Option<String>,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
103
|
+
pub struct Table {
|
|
104
|
+
pub phase: Phase,
|
|
105
|
+
pub players: Vec<u64>,
|
|
106
|
+
pub host: Option<u64>,
|
|
107
|
+
pub cards: CardTable,
|
|
108
|
+
pub suit: String,
|
|
109
|
+
pub turn: usize,
|
|
110
|
+
pub turn_ends: u64,
|
|
111
|
+
/// The current player drew this turn.
|
|
112
|
+
pub drawn: bool,
|
|
113
|
+
pub last: Option<Played>,
|
|
114
|
+
pub winner: Option<u64>,
|
|
115
|
+
pub scores: BTreeMap<u64, i64>,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fn id(p: u64) -> String {
|
|
119
|
+
p.to_string()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
impl Table {
|
|
123
|
+
pub fn current(&self) -> Option<u64> {
|
|
124
|
+
(self.phase == Phase::Playing).then(|| self.players.get(self.turn).copied()).flatten()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fn top(&self) -> Option<&CardId> {
|
|
128
|
+
self.cards.discard.cards.last()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
pub fn view(&self) -> TableView {
|
|
132
|
+
TableView {
|
|
133
|
+
phase: self.phase,
|
|
134
|
+
players: self.players.clone(),
|
|
135
|
+
host: self.host,
|
|
136
|
+
hand_counts: self.players.iter().map(|p| (*p, self.cards.hand(&id(*p)).len())).collect(),
|
|
137
|
+
top: self.top().cloned(),
|
|
138
|
+
suit: (self.phase != Phase::Lobby).then(|| self.suit.clone()),
|
|
139
|
+
draw_count: self.cards.draw.len(),
|
|
140
|
+
turn: self.current(),
|
|
141
|
+
turn_ends: (self.phase == Phase::Playing).then_some(self.turn_ends),
|
|
142
|
+
last: self.last.clone(),
|
|
143
|
+
winner: self.winner,
|
|
144
|
+
scores: self.scores.clone(),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
pub fn hand(&self, player: u64) -> HandView {
|
|
149
|
+
let cards = self.cards.hand(&id(player)).to_vec();
|
|
150
|
+
let mine = self.current() == Some(player);
|
|
151
|
+
let top = self.top().cloned().unwrap_or_default();
|
|
152
|
+
let playable = if mine { cards.iter().filter(|c| playable(c, &top, &self.suit)).cloned().collect() } else { Vec::new() };
|
|
153
|
+
HandView { cards, playable, drawn: mine && self.drawn }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
pub fn join(&mut self, player: u64) -> Result<()> {
|
|
157
|
+
if self.players.contains(&player) {
|
|
158
|
+
return Ok(());
|
|
159
|
+
}
|
|
160
|
+
if self.phase != Phase::Lobby {
|
|
161
|
+
return Err(kit::error(codes::STARTED, "the game has started"));
|
|
162
|
+
}
|
|
163
|
+
if self.players.len() >= MAX_PLAYERS {
|
|
164
|
+
return Err(kit::error(codes::FULL, "the table is full"));
|
|
165
|
+
}
|
|
166
|
+
self.players.push(player);
|
|
167
|
+
self.host.get_or_insert(player);
|
|
168
|
+
Ok(())
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
pub fn leave(&mut self, player: u64, now: u64) -> Vec<u64> {
|
|
172
|
+
let Some(i) = self.players.iter().position(|p| *p == player) else { return Vec::new() };
|
|
173
|
+
if self.phase == Phase::Playing {
|
|
174
|
+
// Their cards go to the bottom of the deck.
|
|
175
|
+
let hand = self.cards.hands.remove(&id(player)).unwrap_or_default();
|
|
176
|
+
for c in hand {
|
|
177
|
+
self.cards.draw.cards.insert(0, c);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
self.players.remove(i);
|
|
181
|
+
if self.host == Some(player) {
|
|
182
|
+
self.host = self.players.first().copied();
|
|
183
|
+
}
|
|
184
|
+
if self.phase == Phase::Playing {
|
|
185
|
+
if self.players.len() < MIN_PLAYERS {
|
|
186
|
+
return self.finish(self.players.first().copied());
|
|
187
|
+
}
|
|
188
|
+
if i < self.turn || self.turn >= self.players.len() {
|
|
189
|
+
self.turn = self.turn.saturating_sub(1) % self.players.len();
|
|
190
|
+
}
|
|
191
|
+
self.turn_ends = now + TURN_MS;
|
|
192
|
+
self.drawn = false;
|
|
193
|
+
}
|
|
194
|
+
Vec::new()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// Deals and turns the first card; returns the players whose hand changed.
|
|
198
|
+
pub fn start(&mut self, player: u64, seed: u64, now: u64) -> Result<Vec<u64>> {
|
|
199
|
+
if self.phase != Phase::Lobby {
|
|
200
|
+
return Err(kit::error(codes::STARTED, "the game has started"));
|
|
201
|
+
}
|
|
202
|
+
if self.host != Some(player) {
|
|
203
|
+
return Err(kit::error(codes::FORBIDDEN, "only the host starts the game"));
|
|
204
|
+
}
|
|
205
|
+
if self.players.len() < MIN_PLAYERS {
|
|
206
|
+
return Err(kit::error("too_few_players", format!("at least {MIN_PLAYERS} players are needed")));
|
|
207
|
+
}
|
|
208
|
+
self.cards = CardTable::new(standard_52(), seed);
|
|
209
|
+
let ids: Vec<String> = self.players.iter().map(|p| id(*p)).collect();
|
|
210
|
+
self.cards.deal(&ids, HAND);
|
|
211
|
+
let first = self.cards.draw.draw().ok_or_else(|| kit::error(codes::EMPTY, "the deck is empty"))?;
|
|
212
|
+
self.suit = split(&first).1.to_owned();
|
|
213
|
+
self.cards.discard.put(first);
|
|
214
|
+
self.phase = Phase::Playing;
|
|
215
|
+
self.turn = 0;
|
|
216
|
+
self.turn_ends = now + TURN_MS;
|
|
217
|
+
self.drawn = false;
|
|
218
|
+
self.scores = self.players.iter().map(|p| (*p, 0)).collect();
|
|
219
|
+
Ok(self.players.clone())
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
fn mine(&self, player: u64) -> Result<()> {
|
|
223
|
+
if self.phase != Phase::Playing {
|
|
224
|
+
return Err(kit::error(codes::CONFLICT, "the game is not being played"));
|
|
225
|
+
}
|
|
226
|
+
if self.current() != Some(player) {
|
|
227
|
+
return Err(kit::error(codes::NOT_YOUR_TURN, "it is not your turn"));
|
|
228
|
+
}
|
|
229
|
+
Ok(())
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
fn next(&mut self, now: u64) {
|
|
233
|
+
self.turn = (self.turn + 1) % self.players.len().max(1);
|
|
234
|
+
self.turn_ends = now + TURN_MS;
|
|
235
|
+
self.drawn = false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
fn finish(&mut self, winner: Option<u64>) -> Vec<u64> {
|
|
239
|
+
self.phase = Phase::Finished;
|
|
240
|
+
self.winner = winner;
|
|
241
|
+
if let Some(w) = winner {
|
|
242
|
+
let won: i64 =
|
|
243
|
+
self.players.iter().filter(|p| **p != w).flat_map(|p| self.cards.hand(&id(*p)).to_vec()).map(|c| card_points(&c)).sum();
|
|
244
|
+
*self.scores.entry(w).or_insert(0) += won;
|
|
245
|
+
}
|
|
246
|
+
Vec::new()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/// Plays a card; returns the players whose hand changed.
|
|
250
|
+
pub fn play(&mut self, player: u64, card: &str, suit: Option<&str>, now: u64) -> Result<Vec<u64>> {
|
|
251
|
+
self.mine(player)?;
|
|
252
|
+
if !self.cards.hand(&id(player)).iter().any(|c| c == card) {
|
|
253
|
+
return Err(kit::error(codes::NOT_FOUND, "that card is not in your hand"));
|
|
254
|
+
}
|
|
255
|
+
let top = self.top().cloned().unwrap_or_default();
|
|
256
|
+
if !playable(card, &top, &self.suit) {
|
|
257
|
+
return Err(kit::error("not_playable", format!("{card} does not go on {top}")));
|
|
258
|
+
}
|
|
259
|
+
let (rank, card_suit) = split(card);
|
|
260
|
+
let next_suit = if rank == "8" {
|
|
261
|
+
match suit {
|
|
262
|
+
Some(s @ ("S" | "H" | "D" | "C")) => s.to_owned(),
|
|
263
|
+
_ => return Err(kit::error(codes::BAD_REQUEST, "an eight names the next suit: S, H, D or C")),
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
card_suit.to_owned()
|
|
267
|
+
};
|
|
268
|
+
self.cards.play(&id(player), card, "table").map_err(|e| kit::error(codes::NOT_FOUND, e))?;
|
|
269
|
+
for (_, c) in self.cards.zones.remove("table").unwrap_or_default() {
|
|
270
|
+
self.cards.discard.put(c);
|
|
271
|
+
}
|
|
272
|
+
self.suit = next_suit;
|
|
273
|
+
self.last = Some(Played { player, card: Some(card.to_owned()), action: "play".into() });
|
|
274
|
+
if self.cards.hand(&id(player)).is_empty() {
|
|
275
|
+
self.finish(Some(player));
|
|
276
|
+
return Ok(vec![player]);
|
|
277
|
+
}
|
|
278
|
+
self.next(now);
|
|
279
|
+
Ok(vec![player])
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/// Draws one card (once a turn); returns the players whose hand changed.
|
|
283
|
+
pub fn draw(&mut self, player: u64) -> Result<Vec<u64>> {
|
|
284
|
+
self.mine(player)?;
|
|
285
|
+
if self.drawn {
|
|
286
|
+
return Err(kit::error(codes::CONFLICT, "you drew this turn: play or pass"));
|
|
287
|
+
}
|
|
288
|
+
// Recycling the discard keeps its top card on the table.
|
|
289
|
+
let top = self.cards.discard.draw();
|
|
290
|
+
let drew = self.cards.draw_to_hand(&id(player));
|
|
291
|
+
if let Some(t) = top {
|
|
292
|
+
self.cards.discard.put(t);
|
|
293
|
+
}
|
|
294
|
+
drew.ok_or_else(|| kit::error(codes::EMPTY, "no cards left to draw"))?;
|
|
295
|
+
self.drawn = true;
|
|
296
|
+
self.last = Some(Played { player, card: None, action: "draw".into() });
|
|
297
|
+
Ok(vec![player])
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
pub fn pass(&mut self, player: u64, now: u64) -> Result<()> {
|
|
301
|
+
self.mine(player)?;
|
|
302
|
+
if !self.drawn {
|
|
303
|
+
return Err(kit::error(codes::CONFLICT, "draw before you pass"));
|
|
304
|
+
}
|
|
305
|
+
self.last = Some(Played { player, card: None, action: "pass".into() });
|
|
306
|
+
self.next(now);
|
|
307
|
+
Ok(())
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// A turn that ran out: the player draws (if they had not) and passes.
|
|
311
|
+
pub fn timeout(&mut self, now: u64) -> Vec<u64> {
|
|
312
|
+
let Some(player) = self.current() else { return Vec::new() };
|
|
313
|
+
if now < self.turn_ends {
|
|
314
|
+
return Vec::new();
|
|
315
|
+
}
|
|
316
|
+
let changed = if self.drawn { Vec::new() } else { self.draw(player).unwrap_or_default() };
|
|
317
|
+
self.last = Some(Played { player, card: None, action: "timeout".into() });
|
|
318
|
+
self.next(now);
|
|
319
|
+
changed
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/// The hub: the table, and what the kit protocol keeps beside it.
|
|
324
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
325
|
+
pub struct TableHub {
|
|
326
|
+
#[serde(flatten)]
|
|
327
|
+
pub table: Table,
|
|
328
|
+
#[serde(default)]
|
|
329
|
+
pub kit: Publisher,
|
|
330
|
+
#[serde(default)]
|
|
331
|
+
pub receipts: Receipts,
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
impl TableHub {
|
|
335
|
+
fn act(&mut self, ctx: &Ctx, call: &Call<'_>, now: u64) -> Result<Vec<u8>> {
|
|
336
|
+
let player = call.player()?;
|
|
337
|
+
let t = &mut self.table;
|
|
338
|
+
let (out, changed) = match call.method {
|
|
339
|
+
"join" => (t.join(player).map(|_| encode(&true))?, Vec::new()),
|
|
340
|
+
"leave" => {
|
|
341
|
+
let changed = t.leave(player, now);
|
|
342
|
+
(encode(&true), changed)
|
|
343
|
+
}
|
|
344
|
+
"start" => {
|
|
345
|
+
let seed = Rng::seed_from(&ctx.key) ^ now ^ ctx.epoch;
|
|
346
|
+
let changed = t.start(player, seed, now)?;
|
|
347
|
+
(encode(&true), changed)
|
|
348
|
+
}
|
|
349
|
+
"play" => {
|
|
350
|
+
let p: Play = kit::args(call)?;
|
|
351
|
+
let changed = t.play(player, &p.card, p.suit.as_deref(), now)?;
|
|
352
|
+
(encode(&(t.phase == Phase::Playing)), changed)
|
|
353
|
+
}
|
|
354
|
+
"draw" => {
|
|
355
|
+
let changed = t.draw(player)?;
|
|
356
|
+
(encode(&t.hand(player)), changed)
|
|
357
|
+
}
|
|
358
|
+
"pass" => {
|
|
359
|
+
t.pass(player, now)?;
|
|
360
|
+
(encode(&true), Vec::new())
|
|
361
|
+
}
|
|
362
|
+
other => return Err(Error::unknown_method(other)),
|
|
363
|
+
};
|
|
364
|
+
self.arm(ctx)?;
|
|
365
|
+
self.publish(ctx, &changed)?;
|
|
366
|
+
ctx.persist_now();
|
|
367
|
+
out
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
fn arm(&self, ctx: &Ctx) -> Result<()> {
|
|
371
|
+
if self.table.phase == Phase::Playing {
|
|
372
|
+
ctx.timer_after("turn", self.table.turn_ends.saturating_sub(ctx.now_ms()).max(1))
|
|
373
|
+
} else {
|
|
374
|
+
ctx.cancel_timer("turn");
|
|
375
|
+
Ok(())
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
fn publish(&mut self, ctx: &Ctx, changed: &[u64]) -> Result<()> {
|
|
380
|
+
self.kit.snapshot(ctx, TOPIC, &self.table.view())?;
|
|
381
|
+
self.kit.changed(ctx, TOPIC, changed, "hand")
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
impl Hub for TableHub {
|
|
386
|
+
fn spawn(_ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
|
|
387
|
+
Ok(Self::default())
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
|
|
391
|
+
decode(snapshot)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
|
|
395
|
+
encode(self)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
399
|
+
let now = ctx.now_ms();
|
|
400
|
+
match call.method {
|
|
401
|
+
"state" => encode(&self.table.view()),
|
|
402
|
+
"hand" => encode(&self.table.hand(call.player()?)),
|
|
403
|
+
kit::SNAPSHOT_METHOD => self.kit.snapshot_reply(ctx, TOPIC, &self.table.view()),
|
|
404
|
+
_ => Receipts::run(self, |h| &mut h.receipts, &call, now, |h| h.act(ctx, &call, now)),
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
|
|
409
|
+
if timer != "turn" {
|
|
410
|
+
return Ok(());
|
|
411
|
+
}
|
|
412
|
+
let changed = self.table.timeout(ctx.now_ms());
|
|
413
|
+
self.arm(ctx)?;
|
|
414
|
+
self.publish(ctx, &changed)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
ckx_sdk::export_hub!(TableHub);
|
|
419
|
+
|
|
420
|
+
#[cfg(test)]
|
|
421
|
+
mod tests {
|
|
422
|
+
use super::*;
|
|
423
|
+
use ckx_sdk::kit::{Changed, Message, MessageKind, error_code};
|
|
424
|
+
use ckx_sdk::testing::TestHub;
|
|
425
|
+
|
|
426
|
+
fn started() -> Table {
|
|
427
|
+
let mut t = Table::default();
|
|
428
|
+
t.join(1).unwrap();
|
|
429
|
+
t.join(2).unwrap();
|
|
430
|
+
t.start(1, 7, 0).unwrap();
|
|
431
|
+
t
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
#[test]
|
|
435
|
+
fn cards_match_suit_or_rank_and_eights_are_wild() {
|
|
436
|
+
assert!(playable("5H", "9H", "H"));
|
|
437
|
+
assert!(playable("9S", "9H", "H"));
|
|
438
|
+
assert!(playable("8C", "2D", "D"));
|
|
439
|
+
assert!(!playable("5S", "9H", "H"));
|
|
440
|
+
assert_eq!(split("10H"), ("10", "H"));
|
|
441
|
+
assert_eq!(card_points("8S") + card_points("KD") + card_points("3C") + card_points("AH"), 64);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
#[test]
|
|
445
|
+
fn hands_are_dealt_hidden_and_only_the_current_player_moves() {
|
|
446
|
+
let mut t = started();
|
|
447
|
+
assert_eq!(t.view().hand_counts, BTreeMap::from([(1, HAND), (2, HAND)]));
|
|
448
|
+
assert_eq!(t.view().draw_count, 52 - 2 * HAND - 1);
|
|
449
|
+
assert!(t.hand(2).playable.is_empty(), "not their turn");
|
|
450
|
+
let Error(why) = t.draw(2).unwrap_err();
|
|
451
|
+
assert_eq!(error_code(&why).map(|c| c.0), Some(codes::NOT_YOUR_TURN));
|
|
452
|
+
t.draw(1).unwrap();
|
|
453
|
+
assert!(t.draw(1).is_err(), "once a turn");
|
|
454
|
+
t.pass(1, 10).unwrap();
|
|
455
|
+
assert_eq!(t.current(), Some(2));
|
|
456
|
+
assert_eq!(t.hand(1).cards.len(), HAND + 1);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#[test]
|
|
460
|
+
fn playing_out_a_hand_wins_the_points_left_in_the_others() {
|
|
461
|
+
let mut t = started();
|
|
462
|
+
let mut guard = 0;
|
|
463
|
+
while t.phase == Phase::Playing && guard < 500 {
|
|
464
|
+
guard += 1;
|
|
465
|
+
let p = t.current().unwrap();
|
|
466
|
+
let h = t.hand(p);
|
|
467
|
+
if let Some(card) = h.playable.first() {
|
|
468
|
+
let suit = (split(card).0 == "8").then_some("H");
|
|
469
|
+
t.play(p, card, suit, guard).unwrap();
|
|
470
|
+
} else if !h.drawn && t.draw(p).is_ok() {
|
|
471
|
+
} else {
|
|
472
|
+
t.pass(p, guard).unwrap_or(());
|
|
473
|
+
if t.current() == Some(p) {
|
|
474
|
+
t.timeout(u64::MAX);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
assert_eq!(t.phase, Phase::Finished);
|
|
479
|
+
let w = t.winner.unwrap();
|
|
480
|
+
assert!(t.hand(w).cards.is_empty());
|
|
481
|
+
assert!(t.scores[&w] > 0);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
#[test]
|
|
485
|
+
fn the_table_topic_is_public_and_a_changed_message_names_who_reads_their_hand() {
|
|
486
|
+
let mut hub = TestHub::<TableHub>::spawn("deck", "t1").unwrap();
|
|
487
|
+
for p in [1, 2] {
|
|
488
|
+
let _: bool = hub.call_typed(Caller::Player(p), "join", &()).unwrap();
|
|
489
|
+
}
|
|
490
|
+
hub.clear();
|
|
491
|
+
let _: bool = hub.call_typed(Caller::Player(1), "start", &()).unwrap();
|
|
492
|
+
#[derive(Deserialize)]
|
|
493
|
+
struct Kind {
|
|
494
|
+
kind: MessageKind,
|
|
495
|
+
}
|
|
496
|
+
let published_now = hub.published();
|
|
497
|
+
let kinds: Vec<MessageKind> = published_now.iter().map(|(_, b)| decode::<Kind>(b).unwrap().kind).collect();
|
|
498
|
+
assert_eq!(kinds, vec![MessageKind::Snapshot, MessageKind::Changed]);
|
|
499
|
+
let changed: Message<Changed> = decode(&published_now[1].1).unwrap();
|
|
500
|
+
let changed = changed.body;
|
|
501
|
+
assert_eq!((changed.who, changed.what.as_str()), (vec![1, 2], "hand"));
|
|
502
|
+
let published = format!("{:?}", hub.published());
|
|
503
|
+
let hand: HandView = hub.call_typed(Caller::Player(2), "hand", &()).unwrap();
|
|
504
|
+
assert_eq!(hand.cards.len(), HAND);
|
|
505
|
+
assert!(hand.cards.iter().all(|c| !published.contains(&format!("\"{c}\""))), "no hidden card is ever published");
|
|
506
|
+
hub.advance(TURN_MS).unwrap();
|
|
507
|
+
let view: TableView = hub.call_typed(Caller::Player(2), "state", &()).unwrap();
|
|
508
|
+
assert_eq!((view.turn, view.hand_counts[&1]), (Some(2), HAND + 1), "a timeout draws and passes");
|
|
509
|
+
}
|
|
510
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "director"
|
|
3
|
+
description = "Starter: an encounter of waves down a lane, towers that may not cut it, a base to hold and a boss with phases"
|
|
4
|
+
version = "0.1.0"
|
|
5
|
+
edition = "2024"
|
|
6
|
+
publish = false
|
|
7
|
+
|
|
8
|
+
[lib]
|
|
9
|
+
crate-type = ["cdylib"]
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
ckx-sdk = "0.11.0"
|
|
13
|
+
serde = { version = "1", features = ["derive"] }
|
|
14
|
+
serde_json = "1"
|