@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
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
//! A leaderboard, keyed by its name (`weekly-score`, `fastest-lap`, ...). The hub ranks players
|
|
2
|
+
//! itself, and only other hubs and developers submit: a client never sends its own score, the
|
|
3
|
+
//! hub that decided the result does (a session, a race, a run). Players read the top, the
|
|
4
|
+
//! places around a player, and their own. A season rolls on a cron schedule, or when a
|
|
5
|
+
//! developer says so, and the board keeps the last season's final standings.
|
|
6
|
+
//!
|
|
7
|
+
//! Endpoints: `submit { player, score }` (another instance, or a developer), `top { limit,
|
|
8
|
+
//! offset }`, `around { player, radius }` (players default to themselves), `me` (players),
|
|
9
|
+
//! `season`, `configure { .. }` and `roll` (developers). It speaks the kit protocol
|
|
10
|
+
//! (`ckx_sdk::kit`): the `board` topic carries the top of the board and the season as
|
|
11
|
+
//! `snapshot` messages, at most once a second, and so does `snapshot { topic: "board" }`;
|
|
12
|
+
//! changes may carry an `action_id`; refusals carry `kit:<code>:` codes.
|
|
13
|
+
|
|
14
|
+
use std::collections::BTreeMap;
|
|
15
|
+
|
|
16
|
+
use ckx_sdk::kit::{self, Publisher, Receipts, codes};
|
|
17
|
+
use ckx_sdk::prelude::*;
|
|
18
|
+
|
|
19
|
+
pub const TOPIC: &str = "board";
|
|
20
|
+
/// How often a changed board is published.
|
|
21
|
+
const PUBLISH_MS: u64 = 1_000;
|
|
22
|
+
const MAX_PAGE: usize = 100;
|
|
23
|
+
|
|
24
|
+
/// How a player's submissions make their score.
|
|
25
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
26
|
+
#[serde(rename_all = "snake_case")]
|
|
27
|
+
pub enum Keep {
|
|
28
|
+
/// Their best submission (by `order`).
|
|
29
|
+
#[default]
|
|
30
|
+
Best,
|
|
31
|
+
/// Every submission added up.
|
|
32
|
+
Sum,
|
|
33
|
+
/// Their last submission.
|
|
34
|
+
Latest,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Which way is better.
|
|
38
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
39
|
+
#[serde(rename_all = "snake_case")]
|
|
40
|
+
pub enum Order {
|
|
41
|
+
/// Higher scores rank first (points).
|
|
42
|
+
#[default]
|
|
43
|
+
High,
|
|
44
|
+
/// Lower scores rank first (times).
|
|
45
|
+
Low,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
49
|
+
#[serde(default)]
|
|
50
|
+
pub struct Config {
|
|
51
|
+
pub keep: Keep,
|
|
52
|
+
pub order: Order,
|
|
53
|
+
/// Entries the `board` topic carries.
|
|
54
|
+
pub top_size: usize,
|
|
55
|
+
/// A cron schedule that ends the season (`0 0 * * 1`: every Monday), or none.
|
|
56
|
+
pub season_cron: Option<String>,
|
|
57
|
+
pub max_entries: usize,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
impl Default for Config {
|
|
61
|
+
fn default() -> Self {
|
|
62
|
+
Self { keep: Keep::Best, order: Order::High, top_size: 10, season_cron: None, max_entries: 100_000 }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
67
|
+
pub struct Entry {
|
|
68
|
+
pub score: i64,
|
|
69
|
+
/// When the score was last improved (or set): the earlier of a tie ranks first.
|
|
70
|
+
pub at: u64,
|
|
71
|
+
pub submissions: u32,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// One place on the board. Players with the same score share a rank.
|
|
75
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
76
|
+
pub struct Ranked {
|
|
77
|
+
pub rank: u32,
|
|
78
|
+
pub player: u64,
|
|
79
|
+
pub score: i64,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// What the `board` topic carries.
|
|
83
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
84
|
+
pub struct BoardView {
|
|
85
|
+
pub season: u32,
|
|
86
|
+
pub season_started: u64,
|
|
87
|
+
pub entries: usize,
|
|
88
|
+
pub top: Vec<Ranked>,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
92
|
+
pub struct SeasonView {
|
|
93
|
+
pub season: u32,
|
|
94
|
+
pub started: u64,
|
|
95
|
+
pub entries: usize,
|
|
96
|
+
/// The final standings of the season before, top first.
|
|
97
|
+
pub last: Vec<Ranked>,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#[derive(Deserialize)]
|
|
101
|
+
struct Submit {
|
|
102
|
+
player: u64,
|
|
103
|
+
score: i64,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#[derive(Deserialize, Default)]
|
|
107
|
+
#[serde(default)]
|
|
108
|
+
struct Page {
|
|
109
|
+
limit: Option<usize>,
|
|
110
|
+
offset: usize,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#[derive(Deserialize, Default)]
|
|
114
|
+
#[serde(default)]
|
|
115
|
+
struct Around {
|
|
116
|
+
player: Option<u64>,
|
|
117
|
+
radius: Option<usize>,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
121
|
+
pub struct Board {
|
|
122
|
+
pub config: Config,
|
|
123
|
+
pub entries: BTreeMap<u64, Entry>,
|
|
124
|
+
pub season: u32,
|
|
125
|
+
pub season_started: u64,
|
|
126
|
+
pub last_season: Vec<Ranked>,
|
|
127
|
+
/// The board changed since it was last published.
|
|
128
|
+
#[serde(default)]
|
|
129
|
+
pub dirty: bool,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
impl Board {
|
|
133
|
+
fn better(&self, a: i64, b: i64) -> bool {
|
|
134
|
+
match self.config.order {
|
|
135
|
+
Order::High => a > b,
|
|
136
|
+
Order::Low => a < b,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// Takes a submission; returns the player's score now.
|
|
141
|
+
pub fn submit(&mut self, player: u64, score: i64, now: u64) -> Result<i64> {
|
|
142
|
+
if !self.entries.contains_key(&player) && self.entries.len() >= self.config.max_entries {
|
|
143
|
+
return Err(kit::error(codes::FULL, "the board holds as many players as it takes"));
|
|
144
|
+
}
|
|
145
|
+
let keep = self.config.keep;
|
|
146
|
+
let improves = self.entries.get(&player).map(|e| self.better(score, e.score));
|
|
147
|
+
let e = self.entries.entry(player).or_insert(Entry { score, at: now, submissions: 0 });
|
|
148
|
+
e.submissions += 1;
|
|
149
|
+
match keep {
|
|
150
|
+
Keep::Best => {
|
|
151
|
+
if improves == Some(true) {
|
|
152
|
+
e.score = score;
|
|
153
|
+
e.at = now;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
Keep::Sum => {
|
|
157
|
+
if e.submissions > 1 {
|
|
158
|
+
e.score = e.score.saturating_add(score);
|
|
159
|
+
}
|
|
160
|
+
e.at = now;
|
|
161
|
+
}
|
|
162
|
+
Keep::Latest => {
|
|
163
|
+
e.score = score;
|
|
164
|
+
e.at = now;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
self.dirty = true;
|
|
168
|
+
Ok(e.score)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Every entry, best first; equal scores share a rank and the earlier one is listed first.
|
|
172
|
+
pub fn ranked(&self) -> Vec<Ranked> {
|
|
173
|
+
let mut all: Vec<(u64, &Entry)> = self.entries.iter().map(|(p, e)| (*p, e)).collect();
|
|
174
|
+
let high = self.config.order == Order::High;
|
|
175
|
+
all.sort_by(|a, b| {
|
|
176
|
+
let by_score = if high { b.1.score.cmp(&a.1.score) } else { a.1.score.cmp(&b.1.score) };
|
|
177
|
+
by_score.then(a.1.at.cmp(&b.1.at)).then(a.0.cmp(&b.0))
|
|
178
|
+
});
|
|
179
|
+
let mut out = Vec::with_capacity(all.len());
|
|
180
|
+
let mut rank = 0;
|
|
181
|
+
let mut last: Option<i64> = None;
|
|
182
|
+
for (i, (player, e)) in all.into_iter().enumerate() {
|
|
183
|
+
if last != Some(e.score) {
|
|
184
|
+
rank = i as u32 + 1;
|
|
185
|
+
last = Some(e.score);
|
|
186
|
+
}
|
|
187
|
+
out.push(Ranked { rank, player, score: e.score });
|
|
188
|
+
}
|
|
189
|
+
out
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
pub fn top(&self, offset: usize, limit: usize) -> Vec<Ranked> {
|
|
193
|
+
self.ranked().into_iter().skip(offset).take(limit.min(MAX_PAGE)).collect()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
pub fn around(&self, player: u64, radius: usize) -> Vec<Ranked> {
|
|
197
|
+
let all = self.ranked();
|
|
198
|
+
let Some(i) = all.iter().position(|r| r.player == player) else { return Vec::new() };
|
|
199
|
+
let radius = radius.min(MAX_PAGE / 2);
|
|
200
|
+
all[i.saturating_sub(radius)..(i + radius + 1).min(all.len())].to_vec()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
pub fn me(&self, player: u64) -> Option<Ranked> {
|
|
204
|
+
self.ranked().into_iter().find(|r| r.player == player)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/// Ends the season: keeps its final standings and starts the next, empty.
|
|
208
|
+
pub fn roll(&mut self, now: u64) {
|
|
209
|
+
self.last_season = self.top(0, MAX_PAGE);
|
|
210
|
+
self.entries.clear();
|
|
211
|
+
self.season += 1;
|
|
212
|
+
self.season_started = now;
|
|
213
|
+
self.dirty = true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
pub fn view(&self) -> BoardView {
|
|
217
|
+
BoardView {
|
|
218
|
+
season: self.season,
|
|
219
|
+
season_started: self.season_started,
|
|
220
|
+
entries: self.entries.len(),
|
|
221
|
+
top: self.top(0, self.config.top_size),
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
fn arm(&self, ctx: &Ctx) -> Result<()> {
|
|
226
|
+
match &self.config.season_cron {
|
|
227
|
+
Some(expr) => ctx.timer_cron("season", &Cron::parse(expr).map_err(|e| kit::error(codes::BAD_REQUEST, e))?),
|
|
228
|
+
None => {
|
|
229
|
+
ctx.cancel_timer("season");
|
|
230
|
+
Ok(())
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/// The hub: the board, and what the kit protocol keeps beside it.
|
|
237
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
238
|
+
pub struct BoardHub {
|
|
239
|
+
#[serde(flatten)]
|
|
240
|
+
pub board: Board,
|
|
241
|
+
#[serde(default)]
|
|
242
|
+
pub kit: Publisher,
|
|
243
|
+
#[serde(default)]
|
|
244
|
+
pub receipts: Receipts,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
fn developer(call: &Call<'_>) -> Result<u64> {
|
|
248
|
+
call.developer().map_err(|_| kit::error(codes::FORBIDDEN, format!("`{}` is for the app's developers", call.method)))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
impl BoardHub {
|
|
252
|
+
fn act(&mut self, ctx: &Ctx, call: &Call<'_>, now: u64) -> Result<Vec<u8>> {
|
|
253
|
+
let board = &mut self.board;
|
|
254
|
+
match call.method {
|
|
255
|
+
"submit" => {
|
|
256
|
+
if !matches!(call.caller, Caller::Instance(_) | Caller::Developer(_)) {
|
|
257
|
+
return Err(kit::error(codes::FORBIDDEN, "scores come from the hub that decided them, not from players"));
|
|
258
|
+
}
|
|
259
|
+
let s: Submit = kit::args(call)?;
|
|
260
|
+
encode(&board.submit(s.player, s.score, now)?)
|
|
261
|
+
}
|
|
262
|
+
"configure" => {
|
|
263
|
+
developer(call)?;
|
|
264
|
+
let config: Config = kit::args(call)?;
|
|
265
|
+
if config.top_size == 0 || config.top_size > MAX_PAGE || config.max_entries == 0 {
|
|
266
|
+
return Err(kit::error(codes::BAD_REQUEST, format!("1 <= top_size <= {MAX_PAGE}, and max_entries is at least 1")));
|
|
267
|
+
}
|
|
268
|
+
board.config = config;
|
|
269
|
+
board.arm(ctx)?;
|
|
270
|
+
board.dirty = true;
|
|
271
|
+
encode(&true)
|
|
272
|
+
}
|
|
273
|
+
"roll" => {
|
|
274
|
+
developer(call)?;
|
|
275
|
+
board.roll(now);
|
|
276
|
+
ctx.persist_now();
|
|
277
|
+
encode(&board.season)
|
|
278
|
+
}
|
|
279
|
+
other => Err(Error::unknown_method(other)),
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
impl Hub for BoardHub {
|
|
285
|
+
fn spawn(ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
|
|
286
|
+
ctx.timer_every("publish", PUBLISH_MS)?;
|
|
287
|
+
let mut hub = Self::default();
|
|
288
|
+
hub.board.season = 1;
|
|
289
|
+
hub.board.season_started = ctx.now_ms();
|
|
290
|
+
Ok(hub)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
|
|
294
|
+
decode(snapshot)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
|
|
298
|
+
encode(self)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
302
|
+
let now = ctx.now_ms();
|
|
303
|
+
let board = &self.board;
|
|
304
|
+
match call.method {
|
|
305
|
+
"top" => {
|
|
306
|
+
let p: Page = kit::args(&call).unwrap_or_default();
|
|
307
|
+
encode(&board.top(p.offset, p.limit.unwrap_or(board.config.top_size)))
|
|
308
|
+
}
|
|
309
|
+
"around" => {
|
|
310
|
+
let a: Around = kit::args(&call).unwrap_or_default();
|
|
311
|
+
let player = match (a.player, call.caller) {
|
|
312
|
+
(Some(p), _) => p,
|
|
313
|
+
(None, Caller::Player(p)) => p,
|
|
314
|
+
_ => return Err(kit::error(codes::BAD_REQUEST, "name a player")),
|
|
315
|
+
};
|
|
316
|
+
encode(&board.around(player, a.radius.unwrap_or(2)))
|
|
317
|
+
}
|
|
318
|
+
"me" => encode(&board.me(call.player()?)),
|
|
319
|
+
"season" => encode(&SeasonView {
|
|
320
|
+
season: board.season,
|
|
321
|
+
started: board.season_started,
|
|
322
|
+
entries: board.entries.len(),
|
|
323
|
+
last: board.last_season.clone(),
|
|
324
|
+
}),
|
|
325
|
+
kit::SNAPSHOT_METHOD => self.kit.snapshot_reply(ctx, TOPIC, &board.view()),
|
|
326
|
+
_ => Receipts::run(self, |h| &mut h.receipts, &call, now, |h| h.act(ctx, &call, now)),
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
|
|
331
|
+
match timer {
|
|
332
|
+
"season" => {
|
|
333
|
+
self.board.roll(ctx.now_ms());
|
|
334
|
+
self.board.arm(ctx)?;
|
|
335
|
+
ctx.persist_now();
|
|
336
|
+
}
|
|
337
|
+
"publish" if self.board.dirty => {}
|
|
338
|
+
_ => return Ok(()),
|
|
339
|
+
}
|
|
340
|
+
self.board.dirty = false;
|
|
341
|
+
self.kit.snapshot(ctx, TOPIC, &self.board.view())
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
ckx_sdk::export_hub!(BoardHub);
|
|
346
|
+
|
|
347
|
+
#[cfg(test)]
|
|
348
|
+
mod tests {
|
|
349
|
+
use super::*;
|
|
350
|
+
use ckx_sdk::kit::{Message, error_code};
|
|
351
|
+
use ckx_sdk::testing::TestHub;
|
|
352
|
+
|
|
353
|
+
#[test]
|
|
354
|
+
fn players_rank_by_their_best_ties_share_a_rank_and_the_earlier_lists_first() {
|
|
355
|
+
let mut b = Board::default();
|
|
356
|
+
b.submit(1, 50, 0).unwrap();
|
|
357
|
+
b.submit(2, 70, 1).unwrap();
|
|
358
|
+
b.submit(3, 70, 2).unwrap();
|
|
359
|
+
assert_eq!(b.submit(1, 40, 3).unwrap(), 50, "a worse score keeps the best");
|
|
360
|
+
let r = b.ranked();
|
|
361
|
+
assert_eq!(r.iter().map(|x| (x.rank, x.player)).collect::<Vec<_>>(), vec![(1, 2), (1, 3), (3, 1)]);
|
|
362
|
+
assert_eq!(b.around(3, 1).len(), 3);
|
|
363
|
+
assert_eq!(b.me(1).unwrap().rank, 3);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
#[test]
|
|
367
|
+
fn times_rank_low_first_and_sums_add_up() {
|
|
368
|
+
let mut times = Board { config: Config { order: Order::Low, ..Config::default() }, ..Default::default() };
|
|
369
|
+
times.submit(1, 61_000, 0).unwrap();
|
|
370
|
+
times.submit(2, 59_500, 0).unwrap();
|
|
371
|
+
assert_eq!(times.submit(1, 58_000, 1).unwrap(), 58_000, "a faster lap improves");
|
|
372
|
+
assert_eq!(times.top(0, 1)[0].player, 1);
|
|
373
|
+
let mut sums = Board { config: Config { keep: Keep::Sum, ..Config::default() }, ..Default::default() };
|
|
374
|
+
sums.submit(1, 5, 0).unwrap();
|
|
375
|
+
assert_eq!(sums.submit(1, 7, 1).unwrap(), 12);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
#[test]
|
|
379
|
+
fn a_season_rolls_on_its_schedule_and_keeps_the_final_standings() {
|
|
380
|
+
let mut hub = TestHub::<BoardHub>::spawn("leaderboard", "weekly").unwrap();
|
|
381
|
+
let e = hub.call(Caller::Player(1), "submit", &encode(&serde_json::json!({ "player": 1, "score": 9 })).unwrap()).unwrap_err();
|
|
382
|
+
assert_eq!(error_code(&e.message).map(|c| c.0), Some(codes::FORBIDDEN), "players do not submit");
|
|
383
|
+
let _: i64 = hub.call_typed(Caller::Instance(4), "submit", &serde_json::json!({ "player": 1, "score": 9 })).unwrap();
|
|
384
|
+
let _: i64 = hub.call_typed(Caller::Instance(4), "submit", &serde_json::json!({ "player": 2, "score": 3 })).unwrap();
|
|
385
|
+
hub.advance(PUBLISH_MS).unwrap();
|
|
386
|
+
let msg: Message<BoardView> = decode(&hub.published().last().unwrap().1).unwrap();
|
|
387
|
+
assert_eq!(msg.body.top.iter().map(|r| r.player).collect::<Vec<_>>(), vec![1, 2]);
|
|
388
|
+
let _: bool = hub.call_typed(Caller::Developer(9), "configure", &serde_json::json!({ "season_cron": "0 0 * * *" })).unwrap();
|
|
389
|
+
hub.advance(24 * 3_600_000).unwrap();
|
|
390
|
+
let season: SeasonView = hub.call_typed(Caller::Player(1), "season", &()).unwrap();
|
|
391
|
+
assert_eq!((season.season, season.entries, season.last.len()), (2, 0, 2));
|
|
392
|
+
let me: Option<Ranked> = hub.call_typed(Caller::Player(1), "me", &()).unwrap();
|
|
393
|
+
assert_eq!(me, None, "the new season starts empty");
|
|
394
|
+
}
|
|
395
|
+
}
|