@crowdedkingdoms/ckx-kit 0.3.0-dev.1 → 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 +5 -2
- 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 +49 -1
- 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 +10 -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/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/minigame/Cargo.toml +14 -0
- package/starters/minigame/src/lib.rs +391 -0
- package/starters/npc-mobs/Cargo.toml +1 -1
- package/starters/racing/Cargo.toml +14 -0
- package/starters/racing/src/lib.rs +1151 -0
- package/starters/session/Cargo.toml +1 -1
- 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
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
//! A run, keyed by its id: one party enters, plays down floors the hub generates from the run's
|
|
2
|
+
//! seed, and the run ends cleared, wiped, abandoned or expired. The dungeon and roguelike shape:
|
|
3
|
+
//! a floor's rooms come from `ckx_sdk::kit::rng` seeded by the run and the floor, so a seed
|
|
4
|
+
//! replays its layout; the hub resolves each room itself (a fight from the party's strength and
|
|
5
|
+
//! a seeded roll, a treasure, a rest; replace [`Run::resolve`] with your combat, or play the
|
|
6
|
+
//! room out in an `arena` or a `session`); a party with nobody standing is wiped and the run is
|
|
7
|
+
//! over (permadeath); a run nobody touches for `idle_ms` expires. Only its party plays it.
|
|
8
|
+
//!
|
|
9
|
+
//! With a `volume` in the config, opening a run reserves a grid of the world for it (scope
|
|
10
|
+
//! `grids.write`) and grants the party's members the config's permission keys on it (scope
|
|
11
|
+
//! `permissions.write`); the run's end deletes the grid. With a `board`, a run that is cleared
|
|
12
|
+
//! or wiped posts each member's depth to that `leaderboard` (the manifest lets `instance` call
|
|
13
|
+
//! `leaderboard`).
|
|
14
|
+
//!
|
|
15
|
+
//! Endpoints: `open { seed }` (a player, whose run it is; or a developer, for `{ party }`),
|
|
16
|
+
//! `invite { player }` and `descend` (the owner), `join` (an invited player), `enter` (a member
|
|
17
|
+
//! standing: resolves the room the party is at), `abandon` (the owner, or a developer), `state`
|
|
18
|
+
//! (anyone). It speaks the kit protocol (`ckx_sdk::kit`): the `run` topic carries the run as
|
|
19
|
+
//! `snapshot` messages; `snapshot { topic: "run" }` answers it; changes may carry an
|
|
20
|
+
//! `action_id`; refusals carry `kit:<code>:` codes.
|
|
21
|
+
|
|
22
|
+
use ckx_sdk::kit::rng::Rng;
|
|
23
|
+
use ckx_sdk::kit::{self, Publisher, Receipts, codes};
|
|
24
|
+
use ckx_sdk::prelude::*;
|
|
25
|
+
use serde_json::json;
|
|
26
|
+
|
|
27
|
+
pub const TOPIC: &str = "run";
|
|
28
|
+
const IDLE_TIMER: &str = "idle";
|
|
29
|
+
const IDLE_CHECK_MS: u64 = 60_000;
|
|
30
|
+
/// Entries the run's log keeps.
|
|
31
|
+
const LOG_MAX: usize = 20;
|
|
32
|
+
|
|
33
|
+
/// Where runs reserve their grids: `slots` volumes of `size` chunks, `stride` chunks apart
|
|
34
|
+
/// along x from `origin`. A run takes the slot its key hashes to.
|
|
35
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
36
|
+
pub struct Volume {
|
|
37
|
+
pub origin: (i64, i64, i64),
|
|
38
|
+
pub size: (i64, i64, i64),
|
|
39
|
+
pub slots: u32,
|
|
40
|
+
pub stride: i64,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
impl Volume {
|
|
44
|
+
pub fn slot_of(&self, key: &str) -> u32 {
|
|
45
|
+
(Rng::seed_from(key) % u64::from(self.slots.max(1))) as u32
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// The chunks slot `slot` covers, both corners included.
|
|
49
|
+
pub fn bounds(&self, slot: u32) -> (ChunkPos, ChunkPos) {
|
|
50
|
+
let x = self.origin.0 + i64::from(slot) * self.stride;
|
|
51
|
+
let low = ChunkPos::new(x, self.origin.1, self.origin.2);
|
|
52
|
+
let high =
|
|
53
|
+
ChunkPos::new(x + self.size.0.max(1) - 1, self.origin.1 + self.size.1.max(1) - 1, self.origin.2 + self.size.2.max(1) - 1);
|
|
54
|
+
(low, high)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
59
|
+
#[serde(default)]
|
|
60
|
+
pub struct Config {
|
|
61
|
+
pub floors: u32,
|
|
62
|
+
pub rooms_per_floor: u32,
|
|
63
|
+
pub party_max: usize,
|
|
64
|
+
pub idle_ms: u64,
|
|
65
|
+
pub start_hp: i64,
|
|
66
|
+
pub start_power: i64,
|
|
67
|
+
/// The `leaderboard` key cleared and wiped runs post their depth to.
|
|
68
|
+
pub board: Option<String>,
|
|
69
|
+
pub volume: Option<Volume>,
|
|
70
|
+
/// The permission keys members get on the run's grid.
|
|
71
|
+
pub member_permissions: Vec<String>,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
impl Default for Config {
|
|
75
|
+
fn default() -> Self {
|
|
76
|
+
Self {
|
|
77
|
+
floors: 3,
|
|
78
|
+
rooms_per_floor: 4,
|
|
79
|
+
party_max: 4,
|
|
80
|
+
idle_ms: 30 * 60_000,
|
|
81
|
+
start_hp: 30,
|
|
82
|
+
start_power: 6,
|
|
83
|
+
board: None,
|
|
84
|
+
volume: None,
|
|
85
|
+
member_permissions: vec!["enter".into(), "build".into()],
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
|
91
|
+
#[serde(rename_all = "snake_case")]
|
|
92
|
+
pub enum RoomKind {
|
|
93
|
+
Fight,
|
|
94
|
+
Treasure,
|
|
95
|
+
Rest,
|
|
96
|
+
Boss,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
100
|
+
pub struct Room {
|
|
101
|
+
pub kind: RoomKind,
|
|
102
|
+
/// What a fight here takes.
|
|
103
|
+
pub power: i64,
|
|
104
|
+
pub done: bool,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
108
|
+
#[serde(rename_all = "snake_case")]
|
|
109
|
+
pub enum Phase {
|
|
110
|
+
#[default]
|
|
111
|
+
Unopened,
|
|
112
|
+
Running,
|
|
113
|
+
Cleared,
|
|
114
|
+
Wiped,
|
|
115
|
+
Abandoned,
|
|
116
|
+
Expired,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
120
|
+
pub struct Member {
|
|
121
|
+
pub player: u64,
|
|
122
|
+
pub hp: i64,
|
|
123
|
+
pub max_hp: i64,
|
|
124
|
+
pub power: i64,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// The run as everyone sees it.
|
|
128
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
129
|
+
pub struct RunView {
|
|
130
|
+
pub phase: Phase,
|
|
131
|
+
pub owner: Option<u64>,
|
|
132
|
+
pub seed: u64,
|
|
133
|
+
pub floor: u32,
|
|
134
|
+
pub floors: u32,
|
|
135
|
+
pub room: usize,
|
|
136
|
+
pub rooms: Vec<Room>,
|
|
137
|
+
pub members: Vec<Member>,
|
|
138
|
+
pub invited: Vec<u64>,
|
|
139
|
+
pub gold: i64,
|
|
140
|
+
pub relics: Vec<String>,
|
|
141
|
+
pub grid: Option<u64>,
|
|
142
|
+
pub log: Vec<String>,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#[derive(Deserialize, Default)]
|
|
146
|
+
#[serde(default)]
|
|
147
|
+
struct Open {
|
|
148
|
+
seed: Option<u64>,
|
|
149
|
+
party: Vec<u64>,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[derive(Deserialize)]
|
|
153
|
+
struct Invite {
|
|
154
|
+
player: u64,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// The rooms of `floor` in a run seeded `seed`; the last floor ends with its boss.
|
|
158
|
+
pub fn floor_rooms(seed: u64, floor: u32, count: u32, last: bool) -> Vec<Room> {
|
|
159
|
+
let mut rng = Rng::seeded(seed ^ u64::from(floor).wrapping_mul(0x9e37_79b9_7f4a_7c15));
|
|
160
|
+
let base = i64::from(floor) * 8;
|
|
161
|
+
let mut rooms: Vec<Room> = (0..count.max(1))
|
|
162
|
+
.map(|_| {
|
|
163
|
+
let kind = match rng.weighted(&[5, 2, 1]) {
|
|
164
|
+
Some(1) => RoomKind::Treasure,
|
|
165
|
+
Some(2) => RoomKind::Rest,
|
|
166
|
+
_ => RoomKind::Fight,
|
|
167
|
+
};
|
|
168
|
+
Room { kind, power: base + rng.range(0, 6), done: false }
|
|
169
|
+
})
|
|
170
|
+
.collect();
|
|
171
|
+
if last && let Some(end) = rooms.last_mut() {
|
|
172
|
+
*end = Room { kind: RoomKind::Boss, power: base * 3, done: false };
|
|
173
|
+
}
|
|
174
|
+
rooms
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const RELICS: [&str; 4] = ["ember charm", "frost sigil", "old map", "lucky coin"];
|
|
178
|
+
|
|
179
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
180
|
+
pub struct Run {
|
|
181
|
+
pub config: Config,
|
|
182
|
+
pub phase: Phase,
|
|
183
|
+
pub owner: Option<u64>,
|
|
184
|
+
pub seed: u64,
|
|
185
|
+
pub floor: u32,
|
|
186
|
+
pub room: usize,
|
|
187
|
+
pub rooms: Vec<Room>,
|
|
188
|
+
pub members: Vec<Member>,
|
|
189
|
+
pub invited: Vec<u64>,
|
|
190
|
+
pub gold: i64,
|
|
191
|
+
pub relics: Vec<String>,
|
|
192
|
+
pub rng: Rng,
|
|
193
|
+
pub grid: Option<u64>,
|
|
194
|
+
pub log: Vec<String>,
|
|
195
|
+
pub last_touch: u64,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
impl Run {
|
|
199
|
+
pub fn view(&self) -> RunView {
|
|
200
|
+
RunView {
|
|
201
|
+
phase: self.phase,
|
|
202
|
+
owner: self.owner,
|
|
203
|
+
seed: self.seed,
|
|
204
|
+
floor: self.floor,
|
|
205
|
+
floors: self.config.floors,
|
|
206
|
+
room: self.room,
|
|
207
|
+
rooms: self.rooms.clone(),
|
|
208
|
+
members: self.members.clone(),
|
|
209
|
+
invited: self.invited.clone(),
|
|
210
|
+
gold: self.gold,
|
|
211
|
+
relics: self.relics.clone(),
|
|
212
|
+
grid: self.grid,
|
|
213
|
+
log: self.log.clone(),
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
fn note(&mut self, line: String) {
|
|
218
|
+
self.log.push(line);
|
|
219
|
+
if self.log.len() > LOG_MAX {
|
|
220
|
+
self.log.remove(0);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
fn member(&self, player: u64) -> Result<&Member> {
|
|
225
|
+
self.members.iter().find(|m| m.player == player).ok_or_else(|| kit::error(codes::NOT_MEMBER, "you are not in this run's party"))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fn running(&self) -> Result<()> {
|
|
229
|
+
match self.phase {
|
|
230
|
+
Phase::Running => Ok(()),
|
|
231
|
+
Phase::Unopened => Err(kit::error(codes::NOT_READY, "the run is not open")),
|
|
232
|
+
_ => Err(kit::error(codes::FINISHED, "the run is over")),
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
fn new_member(&self, player: u64) -> Member {
|
|
237
|
+
Member { player, hp: self.config.start_hp, max_hp: self.config.start_hp, power: self.config.start_power }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
pub fn open(&mut self, party: &[u64], seed: u64, now: u64) -> Result<()> {
|
|
241
|
+
if self.phase != Phase::Unopened {
|
|
242
|
+
return Err(kit::error(codes::STARTED, "this run was opened already"));
|
|
243
|
+
}
|
|
244
|
+
if party.is_empty() || party.len() > self.config.party_max {
|
|
245
|
+
return Err(kit::error(codes::BAD_REQUEST, format!("a party is 1 to {} players", self.config.party_max)));
|
|
246
|
+
}
|
|
247
|
+
self.phase = Phase::Running;
|
|
248
|
+
self.owner = party.first().copied();
|
|
249
|
+
self.members = party.iter().map(|&p| self.new_member(p)).collect();
|
|
250
|
+
self.seed = seed;
|
|
251
|
+
self.rng = Rng::seeded(seed);
|
|
252
|
+
self.floor = 1;
|
|
253
|
+
self.room = 0;
|
|
254
|
+
self.rooms = floor_rooms(seed, 1, self.config.rooms_per_floor, self.config.floors <= 1);
|
|
255
|
+
self.last_touch = now;
|
|
256
|
+
self.note(format!("the run opened with seed {seed}"));
|
|
257
|
+
Ok(())
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
pub fn invite(&mut self, by: u64, player: u64) -> Result<()> {
|
|
261
|
+
self.running()?;
|
|
262
|
+
if self.owner != Some(by) {
|
|
263
|
+
return Err(kit::error(codes::FORBIDDEN, "the run's owner invites"));
|
|
264
|
+
}
|
|
265
|
+
if self.members.len() + self.invited.len() >= self.config.party_max {
|
|
266
|
+
return Err(kit::error(codes::FULL, "the party is full"));
|
|
267
|
+
}
|
|
268
|
+
if !self.invited.contains(&player) && self.members.iter().all(|m| m.player != player) {
|
|
269
|
+
self.invited.push(player);
|
|
270
|
+
}
|
|
271
|
+
Ok(())
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
pub fn join(&mut self, player: u64) -> Result<()> {
|
|
275
|
+
self.running()?;
|
|
276
|
+
if self.members.iter().any(|m| m.player == player) {
|
|
277
|
+
return Ok(());
|
|
278
|
+
}
|
|
279
|
+
let Some(i) = self.invited.iter().position(|&p| p == player) else {
|
|
280
|
+
return Err(kit::error(codes::FORBIDDEN, "only an invited player joins this run"));
|
|
281
|
+
};
|
|
282
|
+
self.invited.remove(i);
|
|
283
|
+
self.members.push(self.new_member(player));
|
|
284
|
+
self.note(format!("player {player} joined"));
|
|
285
|
+
Ok(())
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/// Resolves the room the party is at. Returns what happened, as the log has it.
|
|
289
|
+
pub fn resolve(&mut self, player: u64, now: u64) -> Result<String> {
|
|
290
|
+
self.running()?;
|
|
291
|
+
if self.member(player)?.hp <= 0 {
|
|
292
|
+
return Err(kit::error(codes::FORBIDDEN, "you are down"));
|
|
293
|
+
}
|
|
294
|
+
let Some(room) = self.rooms.get(self.room).cloned() else {
|
|
295
|
+
return Err(kit::error(codes::CONFLICT, "this floor is cleared; the owner descends"));
|
|
296
|
+
};
|
|
297
|
+
self.last_touch = now;
|
|
298
|
+
let standing: Vec<usize> = (0..self.members.len()).filter(|&i| self.members[i].hp > 0).collect();
|
|
299
|
+
let n = standing.len().max(1) as i64;
|
|
300
|
+
let line = match room.kind {
|
|
301
|
+
RoomKind::Treasure => {
|
|
302
|
+
let gold = i64::from(self.floor) * self.rng.range(5, 15);
|
|
303
|
+
self.gold += gold;
|
|
304
|
+
let mut line = format!("treasure: {gold} gold");
|
|
305
|
+
if self.rng.chance(0.3)
|
|
306
|
+
&& let Some(relic) = self.rng.pick(&RELICS)
|
|
307
|
+
{
|
|
308
|
+
self.relics.push((*relic).to_string());
|
|
309
|
+
line.push_str(&format!(" and the {relic}"));
|
|
310
|
+
}
|
|
311
|
+
self.rooms[self.room].done = true;
|
|
312
|
+
line
|
|
313
|
+
}
|
|
314
|
+
RoomKind::Rest => {
|
|
315
|
+
for &i in &standing {
|
|
316
|
+
let m = &mut self.members[i];
|
|
317
|
+
m.hp = (m.hp + m.max_hp / 2).min(m.max_hp);
|
|
318
|
+
}
|
|
319
|
+
self.rooms[self.room].done = true;
|
|
320
|
+
"a rest: the party heals".to_string()
|
|
321
|
+
}
|
|
322
|
+
RoomKind::Fight | RoomKind::Boss => {
|
|
323
|
+
let strength: i64 = standing.iter().map(|&i| self.members[i].power).sum();
|
|
324
|
+
let roll = self.rng.range(0, strength);
|
|
325
|
+
let won = roll + strength / 2 >= room.power;
|
|
326
|
+
let hurt = if won { (room.power + 2 * n - 1) / (2 * n) } else { (room.power + n - 1) / n };
|
|
327
|
+
for &i in &standing {
|
|
328
|
+
let m = &mut self.members[i];
|
|
329
|
+
m.hp = (m.hp - hurt).max(0);
|
|
330
|
+
if won && m.hp > 0 {
|
|
331
|
+
m.power += 1;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if won {
|
|
335
|
+
self.rooms[self.room].done = true;
|
|
336
|
+
let gold = room.power / 2;
|
|
337
|
+
self.gold += gold;
|
|
338
|
+
format!(
|
|
339
|
+
"{}: won ({roll} + {} against {}), {hurt} damage each, {gold} gold",
|
|
340
|
+
kind_name(room.kind),
|
|
341
|
+
strength / 2,
|
|
342
|
+
room.power
|
|
343
|
+
)
|
|
344
|
+
} else {
|
|
345
|
+
format!("{}: lost ({roll} + {} against {}), {hurt} damage each", kind_name(room.kind), strength / 2, room.power)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
self.note(line.clone());
|
|
350
|
+
if self.members.iter().all(|m| m.hp <= 0) {
|
|
351
|
+
self.phase = Phase::Wiped;
|
|
352
|
+
self.note("the party fell".into());
|
|
353
|
+
} else if self.rooms[self.room].done {
|
|
354
|
+
self.room += 1;
|
|
355
|
+
if self.room == self.rooms.len() && self.floor >= self.config.floors {
|
|
356
|
+
self.phase = Phase::Cleared;
|
|
357
|
+
self.note("the run is cleared".into());
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
Ok(line)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
pub fn descend(&mut self, by: u64, now: u64) -> Result<u32> {
|
|
364
|
+
self.running()?;
|
|
365
|
+
if self.owner != Some(by) {
|
|
366
|
+
return Err(kit::error(codes::FORBIDDEN, "the run's owner leads the way down"));
|
|
367
|
+
}
|
|
368
|
+
if self.room < self.rooms.len() {
|
|
369
|
+
return Err(kit::error(codes::NOT_READY, "this floor has rooms left"));
|
|
370
|
+
}
|
|
371
|
+
self.floor += 1;
|
|
372
|
+
self.room = 0;
|
|
373
|
+
self.rooms = floor_rooms(self.seed, self.floor, self.config.rooms_per_floor, self.floor >= self.config.floors);
|
|
374
|
+
self.last_touch = now;
|
|
375
|
+
self.note(format!("down to floor {}", self.floor));
|
|
376
|
+
Ok(self.floor)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
pub fn end(&mut self, phase: Phase) {
|
|
380
|
+
if self.phase == Phase::Running {
|
|
381
|
+
self.phase = phase;
|
|
382
|
+
self.note(format!("the run {}", if phase == Phase::Expired { "expired" } else { "was abandoned" }));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/// Each member's depth: floors reached and rooms done, as one score.
|
|
387
|
+
pub fn depth(&self) -> i64 {
|
|
388
|
+
i64::from(self.floor) * 100 + self.room as i64
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
fn kind_name(kind: RoomKind) -> &'static str {
|
|
393
|
+
match kind {
|
|
394
|
+
RoomKind::Fight => "a fight",
|
|
395
|
+
RoomKind::Boss => "the boss",
|
|
396
|
+
RoomKind::Treasure => "treasure",
|
|
397
|
+
RoomKind::Rest => "a rest",
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/// The hub: the run, and what the kit protocol keeps beside it.
|
|
402
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
403
|
+
pub struct RunHub {
|
|
404
|
+
#[serde(flatten)]
|
|
405
|
+
pub run: Run,
|
|
406
|
+
#[serde(default)]
|
|
407
|
+
pub kit: Publisher,
|
|
408
|
+
#[serde(default)]
|
|
409
|
+
pub receipts: Receipts,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
impl RunHub {
|
|
413
|
+
fn act(&mut self, ctx: &Ctx, call: &Call<'_>, now: u64) -> Result<Vec<u8>> {
|
|
414
|
+
let out = match call.method {
|
|
415
|
+
"open" => {
|
|
416
|
+
let o: Open = kit::args_or_default(call)?;
|
|
417
|
+
let party = match call.caller {
|
|
418
|
+
Caller::Player(p) => vec![p],
|
|
419
|
+
Caller::Developer(_) => o.party,
|
|
420
|
+
_ => return Err(kit::error(codes::FORBIDDEN, "a player or a developer opens a run")),
|
|
421
|
+
};
|
|
422
|
+
let seed = o.seed.unwrap_or_else(|| Rng::seed_from(&ctx.key) ^ now);
|
|
423
|
+
self.run.open(&party, seed, now)?;
|
|
424
|
+
self.reserve(ctx, &party)?;
|
|
425
|
+
ctx.timer_every(IDLE_TIMER, IDLE_CHECK_MS)?;
|
|
426
|
+
encode(&self.run.seed)
|
|
427
|
+
}
|
|
428
|
+
"invite" => {
|
|
429
|
+
let i: Invite = kit::args(call)?;
|
|
430
|
+
self.run.invite(call.player()?, i.player)?;
|
|
431
|
+
encode(&true)
|
|
432
|
+
}
|
|
433
|
+
"join" => {
|
|
434
|
+
let player = call.player()?;
|
|
435
|
+
self.run.join(player)?;
|
|
436
|
+
self.grant(ctx, player)?;
|
|
437
|
+
encode(&true)
|
|
438
|
+
}
|
|
439
|
+
"enter" => encode(&self.run.resolve(call.player()?, now)?),
|
|
440
|
+
"descend" => encode(&self.run.descend(call.player()?, now)?),
|
|
441
|
+
"abandon" => {
|
|
442
|
+
let allowed = match call.caller {
|
|
443
|
+
Caller::Developer(_) => true,
|
|
444
|
+
Caller::Player(p) => self.run.owner == Some(p),
|
|
445
|
+
_ => false,
|
|
446
|
+
};
|
|
447
|
+
if !allowed {
|
|
448
|
+
return Err(kit::error(codes::FORBIDDEN, "the run's owner, or a developer, abandons it"));
|
|
449
|
+
}
|
|
450
|
+
self.run.running()?;
|
|
451
|
+
self.run.end(Phase::Abandoned);
|
|
452
|
+
encode(&true)
|
|
453
|
+
}
|
|
454
|
+
other => return Err(Error::unknown_method(other)),
|
|
455
|
+
};
|
|
456
|
+
self.after(ctx)?;
|
|
457
|
+
out
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/// Reserves the run's grid and grants the party on it, when the config has a volume.
|
|
461
|
+
fn reserve(&mut self, ctx: &Ctx, party: &[u64]) -> Result<()> {
|
|
462
|
+
let Some(volume) = self.run.config.volume.clone() else { return Ok(()) };
|
|
463
|
+
let slot = volume.slot_of(&ctx.key);
|
|
464
|
+
let (low, high) = volume.bounds(slot);
|
|
465
|
+
let grid = ctx.grids().create(low, high).map_err(|e| {
|
|
466
|
+
self.run.phase = Phase::Unopened;
|
|
467
|
+
kit::error(codes::CONFLICT, format!("the run's volume (slot {slot}) could not be reserved: {}", e.message))
|
|
468
|
+
})?;
|
|
469
|
+
self.run.grid = Some(grid.id);
|
|
470
|
+
for &p in party {
|
|
471
|
+
self.grant(ctx, p)?;
|
|
472
|
+
}
|
|
473
|
+
Ok(())
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
fn grant(&self, ctx: &Ctx, player: u64) -> Result<()> {
|
|
477
|
+
if let Some(grid) = self.run.grid {
|
|
478
|
+
let keys: Vec<&str> = self.run.config.member_permissions.iter().map(String::as_str).collect();
|
|
479
|
+
ctx.permissions()
|
|
480
|
+
.grant(grid, player, &keys, None)
|
|
481
|
+
.map_err(|e| kit::error(codes::CONFLICT, format!("granting the run's grid: {}", e.message)))?;
|
|
482
|
+
}
|
|
483
|
+
Ok(())
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/// Once the run is over: the grid goes, the board hears of it, the idle timer stops.
|
|
487
|
+
fn after(&mut self, ctx: &Ctx) -> Result<()> {
|
|
488
|
+
if !matches!(self.run.phase, Phase::Running | Phase::Unopened) {
|
|
489
|
+
ctx.cancel_timer(IDLE_TIMER);
|
|
490
|
+
if let Some(grid) = self.run.grid.take()
|
|
491
|
+
&& let Err(e) = ctx.grids().delete(grid)
|
|
492
|
+
{
|
|
493
|
+
ctx.log(Level::Warn, &format!("the run's grid {grid} was not deleted: {}", e.message));
|
|
494
|
+
}
|
|
495
|
+
if let (Some(board), Phase::Cleared | Phase::Wiped) = (&self.run.config.board, self.run.phase) {
|
|
496
|
+
for m in &self.run.members {
|
|
497
|
+
let submit = json!({ "player": m.player, "score": self.run.depth(), "action_id": format!("{}:{}", ctx.key, m.player) });
|
|
498
|
+
if let Err(e) = ctx.send("leaderboard", board, "submit", &encode(&submit)?) {
|
|
499
|
+
ctx.log(Level::Warn, &format!("the run's depth did not reach board {board}: {}", e.message));
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
ctx.persist_now();
|
|
504
|
+
}
|
|
505
|
+
self.kit.snapshot(ctx, TOPIC, &self.run.view())
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
impl Hub for RunHub {
|
|
510
|
+
fn spawn(_ctx: &Ctx, seed: &[u8]) -> Result<Self> {
|
|
511
|
+
let mut hub = Self::default();
|
|
512
|
+
if !seed.is_empty() {
|
|
513
|
+
hub.run.config = serde_json::from_slice(seed).map_err(|e| Error::new(format!("seed is not a JSON config: {e}")))?;
|
|
514
|
+
}
|
|
515
|
+
Ok(hub)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
|
|
519
|
+
decode(snapshot)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
|
|
523
|
+
encode(self)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
527
|
+
let now = ctx.now_ms();
|
|
528
|
+
match call.method {
|
|
529
|
+
"state" => encode(&self.run.view()),
|
|
530
|
+
kit::SNAPSHOT_METHOD => self.kit.snapshot_reply(ctx, TOPIC, &self.run.view()),
|
|
531
|
+
_ => Receipts::run(self, |h| &mut h.receipts, &call, now, |h| h.act(ctx, &call, now)),
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
|
|
536
|
+
if timer == IDLE_TIMER
|
|
537
|
+
&& self.run.phase == Phase::Running
|
|
538
|
+
&& ctx.now_ms().saturating_sub(self.run.last_touch) >= self.run.config.idle_ms
|
|
539
|
+
{
|
|
540
|
+
self.run.end(Phase::Expired);
|
|
541
|
+
self.after(ctx)?;
|
|
542
|
+
}
|
|
543
|
+
Ok(())
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
ckx_sdk::export_hub!(RunHub);
|
|
548
|
+
|
|
549
|
+
#[cfg(test)]
|
|
550
|
+
mod tests {
|
|
551
|
+
use super::*;
|
|
552
|
+
use ckx_sdk::kit::error_code;
|
|
553
|
+
use ckx_sdk::testing::TestHub;
|
|
554
|
+
|
|
555
|
+
fn code(e: &CallError) -> Option<&str> {
|
|
556
|
+
error_code(&e.message).map(|c| c.0)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
#[test]
|
|
560
|
+
fn a_seed_replays_its_floors_and_the_last_ends_with_the_boss() {
|
|
561
|
+
assert_eq!(floor_rooms(42, 1, 4, false), floor_rooms(42, 1, 4, false));
|
|
562
|
+
assert_ne!(floor_rooms(42, 1, 4, false), floor_rooms(43, 1, 4, false));
|
|
563
|
+
let last = floor_rooms(42, 3, 4, true);
|
|
564
|
+
assert_eq!(last.last().map(|r| r.kind), Some(RoomKind::Boss));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
#[test]
|
|
568
|
+
fn only_the_party_plays_and_a_strong_party_clears_the_run() {
|
|
569
|
+
let mut hub = TestHub::<RunHub>::spawn_with(
|
|
570
|
+
"instance",
|
|
571
|
+
"run-a",
|
|
572
|
+
br#"{"floors":2,"rooms_per_floor":3,"start_hp":500,"start_power":40,"board":"deepest"}"#,
|
|
573
|
+
)
|
|
574
|
+
.unwrap();
|
|
575
|
+
let seed: u64 = hub.call_typed(Caller::Player(1), "open", &json!({ "seed": 7 })).unwrap();
|
|
576
|
+
assert_eq!(seed, 7);
|
|
577
|
+
let e = hub.call(Caller::Player(2), "join", &encode(&()).unwrap()).unwrap_err();
|
|
578
|
+
assert_eq!(code(&e), Some(codes::FORBIDDEN));
|
|
579
|
+
let e = hub.call(Caller::Player(2), "invite", &encode(&json!({ "player": 3 })).unwrap()).unwrap_err();
|
|
580
|
+
assert_eq!(code(&e), Some(codes::FORBIDDEN));
|
|
581
|
+
let _: bool = hub.call_typed(Caller::Player(1), "invite", &json!({ "player": 2 })).unwrap();
|
|
582
|
+
let _: bool = hub.call_typed(Caller::Player(2), "join", &()).unwrap();
|
|
583
|
+
let e = hub.call(Caller::Player(3), "enter", &encode(&()).unwrap()).unwrap_err();
|
|
584
|
+
assert_eq!(code(&e), Some(codes::NOT_MEMBER));
|
|
585
|
+
let mut guard = 0;
|
|
586
|
+
loop {
|
|
587
|
+
let view: RunView = hub.call_typed(Caller::Player(2), "state", &()).unwrap();
|
|
588
|
+
if view.phase != Phase::Running {
|
|
589
|
+
assert_eq!(view.phase, Phase::Cleared, "{:?}", view.log);
|
|
590
|
+
assert_eq!(view.floor, 2);
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
if view.room == view.rooms.len() {
|
|
594
|
+
let e = hub.call(Caller::Player(2), "descend", &encode(&()).unwrap()).unwrap_err();
|
|
595
|
+
assert_eq!(code(&e), Some(codes::FORBIDDEN));
|
|
596
|
+
let _: u32 = hub.call_typed(Caller::Player(1), "descend", &()).unwrap();
|
|
597
|
+
} else {
|
|
598
|
+
let _: String = hub.call_typed(Caller::Player(2), "enter", &()).unwrap();
|
|
599
|
+
}
|
|
600
|
+
guard += 1;
|
|
601
|
+
assert!(guard < 100);
|
|
602
|
+
}
|
|
603
|
+
let sent = hub.sent();
|
|
604
|
+
assert_eq!(sent.len(), 2, "each member's depth goes to the board");
|
|
605
|
+
assert!(sent.iter().all(|m| m.node_type == "leaderboard" && m.key == "deepest" && m.method == "submit"));
|
|
606
|
+
let e = hub.call(Caller::Player(1), "enter", &encode(&()).unwrap()).unwrap_err();
|
|
607
|
+
assert_eq!(code(&e), Some(codes::FINISHED));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
#[test]
|
|
611
|
+
fn a_weak_party_is_wiped_and_the_run_is_over() {
|
|
612
|
+
let mut run = Run { config: Config { start_hp: 5, start_power: 1, ..Config::default() }, ..Default::default() };
|
|
613
|
+
run.open(&[1], 3, 0).unwrap();
|
|
614
|
+
for _ in 0..50 {
|
|
615
|
+
if run.phase != Phase::Running {
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
if run.room == run.rooms.len() {
|
|
619
|
+
run.descend(1, 0).unwrap();
|
|
620
|
+
} else {
|
|
621
|
+
run.resolve(1, 0).unwrap();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
assert_eq!(run.phase, Phase::Wiped, "{:?}", run.log);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
#[test]
|
|
628
|
+
fn a_run_nobody_touches_expires() {
|
|
629
|
+
let mut hub = TestHub::<RunHub>::spawn_with("instance", "run-b", br#"{"idle_ms":120000}"#).unwrap();
|
|
630
|
+
let _: u64 = hub.call_typed(Caller::Player(1), "open", &()).unwrap();
|
|
631
|
+
hub.advance(3 * IDLE_CHECK_MS).unwrap();
|
|
632
|
+
let view: RunView = hub.call_typed(Caller::Player(1), "state", &()).unwrap();
|
|
633
|
+
assert_eq!(view.phase, Phase::Expired);
|
|
634
|
+
assert!(hub.timers().is_empty());
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
#[test]
|
|
638
|
+
fn a_volume_is_reserved_for_the_run_granted_to_its_party_and_deleted_at_its_end() {
|
|
639
|
+
let mut hub =
|
|
640
|
+
TestHub::<RunHub>::spawn_with("instance", "run-c", br#"{"volume":{"origin":[1000,0,0],"size":[4,2,4],"slots":8,"stride":8}}"#)
|
|
641
|
+
.unwrap();
|
|
642
|
+
hub.answer_node("grids.create", |body| Ok(json!({ "grid": { "gridId": "55", "low": body["low"], "high": body["high"] } })));
|
|
643
|
+
hub.answer_node("permissions.grant", |_| Ok(json!({ "expiresAt": null })));
|
|
644
|
+
hub.answer_node("grids.delete", |_| Ok(json!({})));
|
|
645
|
+
let _: u64 = hub.call_typed(Caller::Player(1), "open", &()).unwrap();
|
|
646
|
+
let view: RunView = hub.call_typed(Caller::Player(1), "state", &()).unwrap();
|
|
647
|
+
assert_eq!(view.grid, Some(55));
|
|
648
|
+
let _: bool = hub.call_typed(Caller::Player(1), "abandon", &()).unwrap();
|
|
649
|
+
let ops: Vec<String> = hub.node_requests().into_iter().map(|(op, _)| op).collect();
|
|
650
|
+
assert_eq!(ops, vec!["grids.create", "permissions.grant", "grids.delete"]);
|
|
651
|
+
let create = &hub.node_requests()[0].1;
|
|
652
|
+
let slot = i64::from(Volume { origin: (1000, 0, 0), size: (4, 2, 4), slots: 8, stride: 8 }.slot_of("run-c"));
|
|
653
|
+
assert_eq!(create["low"]["x"], json!((1000 + slot * 8).to_string()));
|
|
654
|
+
assert_eq!(create["high"]["y"], json!("1"));
|
|
655
|
+
}
|
|
656
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "leaderboard"
|
|
3
|
+
description = "Starter: a leaderboard the hub ranks itself, with ties, time boards, sums and seasons on a schedule"
|
|
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"
|