@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,292 @@
|
|
|
1
|
+
//! The combat referee: the hub decides every hit, its damage and who dies; health never moves on
|
|
2
|
+
//! a client's say-so. Pieces a hub keeps in its state:
|
|
3
|
+
//!
|
|
4
|
+
//! - [`Health`] and [`Combatant`]: hit points, status effects that tick (poison, burning,
|
|
5
|
+
//! regeneration), damage and healing with kill detection.
|
|
6
|
+
//! - [`Cooldowns`]: per-actor, per-action minimum intervals (an attack, a cast).
|
|
7
|
+
//! - [`in_reach`]: a target in range of the attacker, from positions the hub saw (`presence`).
|
|
8
|
+
//! - [`RespawnBook`]: kill credit and respawn timers.
|
|
9
|
+
//! - [`ContactBook`]: a hostile's touch on a player, once per cooldown (event 77).
|
|
10
|
+
|
|
11
|
+
use std::collections::BTreeMap;
|
|
12
|
+
|
|
13
|
+
use serde::{Deserialize, Serialize};
|
|
14
|
+
|
|
15
|
+
/// Hit points with a maximum.
|
|
16
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
17
|
+
pub struct Health {
|
|
18
|
+
pub current: i64,
|
|
19
|
+
pub max: i64,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// What damage did.
|
|
23
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
|
24
|
+
pub struct DamageOutcome {
|
|
25
|
+
/// Health after it.
|
|
26
|
+
pub health: i64,
|
|
27
|
+
/// Damage actually taken (never more than the health it had).
|
|
28
|
+
pub dealt: i64,
|
|
29
|
+
pub killed: bool,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
impl Health {
|
|
33
|
+
pub fn new(max: i64) -> Self {
|
|
34
|
+
Self { current: max.max(1), max: max.max(1) }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
pub fn alive(&self) -> bool {
|
|
38
|
+
self.current > 0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pub fn apply(&mut self, amount: i64) -> DamageOutcome {
|
|
42
|
+
let before = self.current;
|
|
43
|
+
self.current = (self.current - amount.max(0)).max(0);
|
|
44
|
+
DamageOutcome { health: self.current, dealt: before - self.current, killed: before > 0 && self.current == 0 }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Heals up to the maximum; a dead one stays dead. Returns what was healed.
|
|
48
|
+
pub fn heal(&mut self, amount: i64) -> i64 {
|
|
49
|
+
if !self.alive() {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
let before = self.current;
|
|
53
|
+
self.current = (self.current + amount.max(0)).min(self.max);
|
|
54
|
+
self.current - before
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pub fn revive(&mut self) {
|
|
58
|
+
self.current = self.max;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// A status effect: `per_tick` damage (negative heals) every `every_ms` until `until_ms`.
|
|
63
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
64
|
+
pub struct Status {
|
|
65
|
+
pub kind: String,
|
|
66
|
+
pub until_ms: u64,
|
|
67
|
+
pub per_tick: i64,
|
|
68
|
+
pub every_ms: u64,
|
|
69
|
+
pub next_ms: u64,
|
|
70
|
+
/// Who applied it, for kill credit.
|
|
71
|
+
#[serde(default)]
|
|
72
|
+
pub source: Option<String>,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Something that fights: health and the effects on it.
|
|
76
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
|
77
|
+
pub struct Combatant {
|
|
78
|
+
pub health: Health,
|
|
79
|
+
#[serde(default)]
|
|
80
|
+
pub statuses: Vec<Status>,
|
|
81
|
+
#[serde(default)]
|
|
82
|
+
pub kills: u32,
|
|
83
|
+
#[serde(default)]
|
|
84
|
+
pub deaths: u32,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// What a status tick did to a combatant.
|
|
88
|
+
#[derive(Clone, Debug, PartialEq)]
|
|
89
|
+
pub struct TickEffect {
|
|
90
|
+
pub kind: String,
|
|
91
|
+
pub outcome: DamageOutcome,
|
|
92
|
+
pub source: Option<String>,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
impl Combatant {
|
|
96
|
+
pub fn new(max_hp: i64) -> Self {
|
|
97
|
+
Self { health: Health::new(max_hp), ..Default::default() }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Applies `kind` (replacing one of the same kind), first ticking `every_ms` from `now`.
|
|
101
|
+
pub fn afflict(&mut self, kind: &str, per_tick: i64, every_ms: u64, duration_ms: u64, source: Option<String>, now: u64) {
|
|
102
|
+
self.statuses.retain(|s| s.kind != kind);
|
|
103
|
+
let every_ms = every_ms.max(100);
|
|
104
|
+
self.statuses.push(Status {
|
|
105
|
+
kind: kind.to_owned(),
|
|
106
|
+
until_ms: now + duration_ms,
|
|
107
|
+
per_tick,
|
|
108
|
+
every_ms,
|
|
109
|
+
next_ms: now + every_ms,
|
|
110
|
+
source,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
pub fn has(&self, kind: &str) -> bool {
|
|
115
|
+
self.statuses.iter().any(|s| s.kind == kind)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// Runs every tick due by `now`, drops expired effects, and returns what each did.
|
|
119
|
+
pub fn tick(&mut self, now: u64) -> Vec<TickEffect> {
|
|
120
|
+
let mut out = Vec::new();
|
|
121
|
+
for s in &mut self.statuses {
|
|
122
|
+
while s.next_ms <= now.min(s.until_ms) && self.health.alive() {
|
|
123
|
+
s.next_ms += s.every_ms;
|
|
124
|
+
let outcome = if s.per_tick >= 0 {
|
|
125
|
+
self.health.apply(s.per_tick)
|
|
126
|
+
} else {
|
|
127
|
+
let healed = self.health.heal(-s.per_tick);
|
|
128
|
+
DamageOutcome { health: self.health.current, dealt: -healed, killed: false }
|
|
129
|
+
};
|
|
130
|
+
out.push(TickEffect { kind: s.kind.clone(), outcome, source: s.source.clone() });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
self.statuses.retain(|s| s.until_ms > now && s.next_ms <= s.until_ms);
|
|
134
|
+
if out.iter().any(|e| e.outcome.killed) {
|
|
135
|
+
self.deaths += 1;
|
|
136
|
+
self.statuses.clear();
|
|
137
|
+
}
|
|
138
|
+
out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Damage from `amount`; counts the death.
|
|
142
|
+
pub fn hit(&mut self, amount: i64) -> DamageOutcome {
|
|
143
|
+
let outcome = self.health.apply(amount);
|
|
144
|
+
if outcome.killed {
|
|
145
|
+
self.deaths += 1;
|
|
146
|
+
self.statuses.clear();
|
|
147
|
+
}
|
|
148
|
+
outcome
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Per-actor, per-action last-use times.
|
|
153
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
|
154
|
+
pub struct Cooldowns {
|
|
155
|
+
#[serde(default)]
|
|
156
|
+
pub last: BTreeMap<String, u64>,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
impl Cooldowns {
|
|
160
|
+
fn key(actor: &str, action: &str) -> String {
|
|
161
|
+
format!("{actor}\u{0}{action}")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// Whether `actor` may `action` at `now` with `cooldown_ms` between uses; when it may, the
|
|
165
|
+
/// use is taken.
|
|
166
|
+
pub fn take(&mut self, actor: &str, action: &str, cooldown_ms: u64, now: u64) -> bool {
|
|
167
|
+
let k = Self::key(actor, action);
|
|
168
|
+
if self.last.get(&k).is_some_and(|at| now < at + cooldown_ms) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
self.last.insert(k, now);
|
|
172
|
+
true
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// Milliseconds until `actor` may `action` again; 0 when it may now.
|
|
176
|
+
pub fn remaining(&self, actor: &str, action: &str, cooldown_ms: u64, now: u64) -> u64 {
|
|
177
|
+
self.last.get(&Self::key(actor, action)).map(|at| (at + cooldown_ms).saturating_sub(now)).unwrap_or(0)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/// Forgets uses older than `older_than_ms`.
|
|
181
|
+
pub fn prune(&mut self, now: u64, older_than_ms: u64) {
|
|
182
|
+
self.last.retain(|_, at| now.saturating_sub(*at) < older_than_ms);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// Whether `target` is within `range` chunks of `attacker` on the ground.
|
|
187
|
+
pub fn in_reach(attacker: (f64, f64), target: (f64, f64), range: f64) -> bool {
|
|
188
|
+
super::super::presence::ground_distance(attacker, target) <= range
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// A kill: who did it, when, and when the victim comes back.
|
|
192
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
193
|
+
pub struct KillRecord {
|
|
194
|
+
pub killer: String,
|
|
195
|
+
pub at_ms: u64,
|
|
196
|
+
pub respawn_at_ms: u64,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/// Kill credit and respawn timers, by victim id.
|
|
200
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
|
201
|
+
pub struct RespawnBook {
|
|
202
|
+
#[serde(default)]
|
|
203
|
+
pub kills: BTreeMap<String, KillRecord>,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
impl RespawnBook {
|
|
207
|
+
pub fn record_kill(&mut self, victim: &str, killer: &str, now: u64, respawn_ms: u64) {
|
|
208
|
+
self.kills.insert(victim.to_owned(), KillRecord { killer: killer.to_owned(), at_ms: now, respawn_at_ms: now + respawn_ms });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Victims whose respawn came due, taken out of the book.
|
|
212
|
+
pub fn due(&mut self, now: u64) -> Vec<String> {
|
|
213
|
+
let ready: Vec<String> = self.kills.iter().filter(|(_, r)| now >= r.respawn_at_ms).map(|(id, _)| id.clone()).collect();
|
|
214
|
+
for id in &ready {
|
|
215
|
+
self.kills.remove(id);
|
|
216
|
+
}
|
|
217
|
+
ready
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
pub fn is_down(&self, victim: &str) -> bool {
|
|
221
|
+
self.kills.contains_key(victim)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/// A hostile's touch on a player, at most once per victim per cooldown.
|
|
226
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
|
227
|
+
pub struct ContactBook {
|
|
228
|
+
#[serde(default)]
|
|
229
|
+
pub at: BTreeMap<String, u64>,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
impl ContactBook {
|
|
233
|
+
/// Whether the hostile at `attacker` touches the victim at `victim` (within `range`) now;
|
|
234
|
+
/// when it does, the victim's cooldown starts.
|
|
235
|
+
pub fn touch(&mut self, victim: &str, victim_at: (f64, f64), attacker: (f64, f64), range: f64, cooldown_ms: u64, now: u64) -> bool {
|
|
236
|
+
if self.at.get(victim).is_some_and(|t| now.saturating_sub(*t) < cooldown_ms) || !in_reach(attacker, victim_at, range) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
self.at.insert(victim.to_owned(), now);
|
|
240
|
+
true
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#[cfg(test)]
|
|
245
|
+
mod tests {
|
|
246
|
+
use super::*;
|
|
247
|
+
|
|
248
|
+
#[test]
|
|
249
|
+
fn damage_kills_once_and_healing_stops_at_the_maximum() {
|
|
250
|
+
let mut h = Health::new(10);
|
|
251
|
+
assert_eq!(h.apply(4), DamageOutcome { health: 6, dealt: 4, killed: false });
|
|
252
|
+
assert_eq!(h.heal(99), 4);
|
|
253
|
+
assert_eq!(h.apply(30), DamageOutcome { health: 0, dealt: 10, killed: true });
|
|
254
|
+
assert!(!h.apply(1).killed, "dead already");
|
|
255
|
+
assert_eq!(h.heal(5), 0, "the dead stay dead");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#[test]
|
|
259
|
+
fn statuses_tick_on_time_expire_and_credit_their_source() {
|
|
260
|
+
let mut c = Combatant::new(20);
|
|
261
|
+
c.afflict("poison", 3, 1_000, 3_500, Some("p7".into()), 0);
|
|
262
|
+
assert!(c.tick(500).is_empty());
|
|
263
|
+
let ticks = c.tick(3_600);
|
|
264
|
+
assert_eq!(ticks.len(), 3);
|
|
265
|
+
assert_eq!(c.health.current, 11);
|
|
266
|
+
assert!(!c.has("poison"), "expired");
|
|
267
|
+
c.afflict("regen", -5, 1_000, 10_000, None, 4_000);
|
|
268
|
+
c.tick(6_000);
|
|
269
|
+
assert_eq!(c.health.current, 20, "healing stops at the maximum");
|
|
270
|
+
c.afflict("burn", 50, 500, 2_000, Some("p9".into()), 7_000);
|
|
271
|
+
let burn = c.tick(7_600);
|
|
272
|
+
assert!(burn.last().unwrap().outcome.killed && burn.last().unwrap().source.as_deref() == Some("p9"));
|
|
273
|
+
assert_eq!((c.deaths, c.statuses.len()), (1, 0));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#[test]
|
|
277
|
+
fn cooldowns_respawns_and_contact() {
|
|
278
|
+
let mut cd = Cooldowns::default();
|
|
279
|
+
assert!(cd.take("p1", "attack", 500, 0));
|
|
280
|
+
assert!(!cd.take("p1", "attack", 500, 300));
|
|
281
|
+
assert_eq!(cd.remaining("p1", "attack", 500, 300), 200);
|
|
282
|
+
assert!(cd.take("p1", "cast", 500, 300), "another action");
|
|
283
|
+
let mut rb = RespawnBook::default();
|
|
284
|
+
rb.record_kill("mob-3", "p1", 0, 1_000);
|
|
285
|
+
assert!(rb.is_down("mob-3"));
|
|
286
|
+
assert_eq!(rb.due(1_000), vec!["mob-3".to_string()]);
|
|
287
|
+
let mut cb = ContactBook::default();
|
|
288
|
+
assert!(!cb.touch("p1", (0.0, 0.0), (3.0, 0.0), 1.5, 1_000, 0), "too far");
|
|
289
|
+
assert!(cb.touch("p1", (0.0, 0.0), (1.0, 0.0), 1.5, 1_000, 0));
|
|
290
|
+
assert!(!cb.touch("p1", (0.0, 0.0), (1.0, 0.0), 1.5, 1_000, 500), "cooldown");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//! Gameplay rules a hub keeps: [`turns`] (initiative, timeouts, simultaneous reveal),
|
|
2
|
+
//! [`score`] (score books, win conditions, standings), [`cards`] (decks, hands held as the
|
|
3
|
+
//! hub's secret, public piles), [`combat`] (the hit referee: health, statuses, cooldowns,
|
|
4
|
+
//! respawns), [`abilities`] (casts with cooldowns and resources, projectiles, area damage),
|
|
5
|
+
//! [`movement`] (the warden for positions clients report) and [`timing`] (checkpoints, laps,
|
|
6
|
+
//! splits, ghosts). Each is plain data over `now_ms` and a seeded [`Rng`](super::Rng): keep it
|
|
7
|
+
//! in the hub's state, and it carries on after a snapshot.
|
|
8
|
+
|
|
9
|
+
pub mod abilities;
|
|
10
|
+
pub mod cards;
|
|
11
|
+
pub mod combat;
|
|
12
|
+
pub mod movement;
|
|
13
|
+
pub mod score;
|
|
14
|
+
pub mod timing;
|
|
15
|
+
pub mod turns;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
//! The movement warden: checks the positions a player's client reports (how far since the last
|
|
2
|
+
//! one, how fast, inside the bounds) and says which position the hub keeps. A hub that takes
|
|
3
|
+
//! positions from clients (a race, a ball game, an arena) runs every report through it; one that
|
|
4
|
+
//! reads where the platform sees players ([`super::super::presence`]) need not.
|
|
5
|
+
//!
|
|
6
|
+
//! A report too fast is cut to the farthest the player could have gone; a jump past `teleport`
|
|
7
|
+
//! is refused (the player stays put); one outside the bounds is pulled back in. Each is a
|
|
8
|
+
//! [`Violation`] the hub may publish (the realtime registry's 95) and counts as a strike;
|
|
9
|
+
//! strikes clear after [`STRIKE_WINDOW_MS`] without one, so a hub can act on a run of them
|
|
10
|
+
//! (drop the player, report them) and forgive a lag spike.
|
|
11
|
+
|
|
12
|
+
use serde::{Deserialize, Serialize};
|
|
13
|
+
|
|
14
|
+
/// How long strikes are remembered after the last one.
|
|
15
|
+
pub const STRIKE_WINDOW_MS: u64 = 10_000;
|
|
16
|
+
|
|
17
|
+
/// What a player's movement may do, in the hub's own units.
|
|
18
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
|
|
19
|
+
#[serde(default)]
|
|
20
|
+
pub struct Rules {
|
|
21
|
+
/// Units a second.
|
|
22
|
+
pub max_speed: f64,
|
|
23
|
+
/// A move this far or more at once is refused.
|
|
24
|
+
pub teleport: f64,
|
|
25
|
+
/// `(min_x, min_z, max_x, max_z)`, when there are any.
|
|
26
|
+
pub bounds: Option<(f64, f64, f64, f64)>,
|
|
27
|
+
/// Slack on `max_speed` for jitter in the reports' timing (1.25 lets a report be 25% fast).
|
|
28
|
+
pub grace: f64,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
impl Default for Rules {
|
|
32
|
+
fn default() -> Self {
|
|
33
|
+
Self { max_speed: 8.0, teleport: 30.0, bounds: None, grace: 1.25 }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
|
38
|
+
#[serde(rename_all = "snake_case")]
|
|
39
|
+
pub enum ViolationKind {
|
|
40
|
+
Speed,
|
|
41
|
+
Teleport,
|
|
42
|
+
Bounds,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
impl ViolationKind {
|
|
46
|
+
pub fn as_str(self) -> &'static str {
|
|
47
|
+
match self {
|
|
48
|
+
Self::Speed => "speed",
|
|
49
|
+
Self::Teleport => "teleport",
|
|
50
|
+
Self::Bounds => "bounds",
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
56
|
+
pub struct Violation {
|
|
57
|
+
pub kind: ViolationKind,
|
|
58
|
+
pub detail: String,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// The position the hub keeps for a report, and what was wrong with it.
|
|
62
|
+
#[derive(Clone, Debug, PartialEq)]
|
|
63
|
+
pub struct Verdict {
|
|
64
|
+
pub at: (f64, f64),
|
|
65
|
+
pub violation: Option<Violation>,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// One player's movement as the hub last accepted it.
|
|
69
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq)]
|
|
70
|
+
pub struct Warden {
|
|
71
|
+
/// The position kept, and when.
|
|
72
|
+
pub last: Option<(f64, f64, u64)>,
|
|
73
|
+
pub strikes: u32,
|
|
74
|
+
pub last_strike_ms: u64,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn clamp_to(bounds: Option<(f64, f64, f64, f64)>, p: (f64, f64)) -> (f64, f64) {
|
|
78
|
+
match bounds {
|
|
79
|
+
Some((x0, z0, x1, z1)) => (p.0.clamp(x0.min(x1), x0.max(x1)), p.1.clamp(z0.min(z1), z0.max(z1))),
|
|
80
|
+
None => p,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
impl Warden {
|
|
85
|
+
/// A warden that starts from `at`: a spawn, a respawn, a place the hub moved the player to.
|
|
86
|
+
pub fn at(at: (f64, f64), now: u64) -> Self {
|
|
87
|
+
Self { last: Some((at.0, at.1, now)), ..Self::default() }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Moves the player where the hub says, with no check.
|
|
91
|
+
pub fn place(&mut self, at: (f64, f64), now: u64) {
|
|
92
|
+
self.last = Some((at.0, at.1, now));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// The position kept.
|
|
96
|
+
pub fn position(&self) -> Option<(f64, f64)> {
|
|
97
|
+
self.last.map(|(x, z, _)| (x, z))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Strikes in the current window.
|
|
101
|
+
pub fn strikes(&self, now: u64) -> u32 {
|
|
102
|
+
if now.saturating_sub(self.last_strike_ms) >= STRIKE_WINDOW_MS { 0 } else { self.strikes }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Checks a reported position and keeps the one the rules allow.
|
|
106
|
+
pub fn check(&mut self, rules: &Rules, claimed: (f64, f64), now: u64) -> Verdict {
|
|
107
|
+
let mut violation = None;
|
|
108
|
+
let mut at = claimed;
|
|
109
|
+
if !at.0.is_finite() || !at.1.is_finite() {
|
|
110
|
+
let kept = self.position().unwrap_or((0.0, 0.0));
|
|
111
|
+
return self.strike(now, kept, ViolationKind::Teleport, "a position that is not a number".into());
|
|
112
|
+
}
|
|
113
|
+
let inside = clamp_to(rules.bounds, at);
|
|
114
|
+
if inside != at {
|
|
115
|
+
violation =
|
|
116
|
+
Some(Violation { kind: ViolationKind::Bounds, detail: format!("({:.1}, {:.1}) is outside the bounds", at.0, at.1) });
|
|
117
|
+
at = inside;
|
|
118
|
+
}
|
|
119
|
+
if let Some((x, z, t)) = self.last {
|
|
120
|
+
let (dx, dz) = (at.0 - x, at.1 - z);
|
|
121
|
+
let dist = (dx * dx + dz * dz).sqrt();
|
|
122
|
+
let dt = now.saturating_sub(t) as f64 / 1000.0;
|
|
123
|
+
let allowed = rules.max_speed * dt * rules.grace.max(1.0) + 0.5;
|
|
124
|
+
if dist >= rules.teleport {
|
|
125
|
+
return self.strike(now, (x, z), ViolationKind::Teleport, format!("{dist:.1} units at once"));
|
|
126
|
+
}
|
|
127
|
+
if dist > allowed {
|
|
128
|
+
let k = allowed / dist;
|
|
129
|
+
at = (x + dx * k, z + dz * k);
|
|
130
|
+
violation = Some(Violation { kind: ViolationKind::Speed, detail: format!("{:.1} units a second", dist / dt.max(0.001)) });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
self.last = Some((at.0, at.1, now));
|
|
134
|
+
match violation {
|
|
135
|
+
Some(v) => self.strike(now, at, v.kind, v.detail),
|
|
136
|
+
None => Verdict { at, violation: None },
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fn strike(&mut self, now: u64, at: (f64, f64), kind: ViolationKind, detail: String) -> Verdict {
|
|
141
|
+
self.strikes = self.strikes(now) + 1;
|
|
142
|
+
self.last_strike_ms = now;
|
|
143
|
+
if kind == ViolationKind::Teleport {
|
|
144
|
+
let t = self.last.map(|l| l.2).unwrap_or(now);
|
|
145
|
+
self.last = Some((at.0, at.1, t));
|
|
146
|
+
}
|
|
147
|
+
Verdict { at, violation: Some(Violation { kind, detail }) }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#[cfg(test)]
|
|
152
|
+
mod tests {
|
|
153
|
+
use super::*;
|
|
154
|
+
|
|
155
|
+
fn rules() -> Rules {
|
|
156
|
+
Rules { max_speed: 10.0, teleport: 25.0, bounds: Some((0.0, 0.0, 100.0, 100.0)), grace: 1.0 }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#[test]
|
|
160
|
+
fn a_fair_move_is_kept() {
|
|
161
|
+
let mut w = Warden::at((10.0, 10.0), 0);
|
|
162
|
+
let v = w.check(&rules(), (14.0, 10.0), 500);
|
|
163
|
+
assert_eq!(v, Verdict { at: (14.0, 10.0), violation: None });
|
|
164
|
+
assert_eq!(w.strikes(500), 0);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#[test]
|
|
168
|
+
fn a_fast_move_is_cut_to_what_the_speed_allows() {
|
|
169
|
+
let mut w = Warden::at((10.0, 10.0), 0);
|
|
170
|
+
let v = w.check(&rules(), (20.0, 10.0), 500);
|
|
171
|
+
assert_eq!(v.violation.map(|v| v.kind), Some(ViolationKind::Speed));
|
|
172
|
+
assert!((v.at.0 - 15.5).abs() < 1e-9 && v.at.1 == 10.0, "{:?}", v.at);
|
|
173
|
+
assert_eq!(w.position(), Some(v.at));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#[test]
|
|
177
|
+
fn a_teleport_is_refused_and_the_player_stays_put() {
|
|
178
|
+
let mut w = Warden::at((10.0, 10.0), 0);
|
|
179
|
+
let v = w.check(&rules(), (60.0, 10.0), 10_000);
|
|
180
|
+
assert_eq!((v.at, v.violation.map(|v| v.kind)), ((10.0, 10.0), Some(ViolationKind::Teleport)));
|
|
181
|
+
let v = w.check(&rules(), (f64::NAN, 1.0), 10_100);
|
|
182
|
+
assert_eq!((v.at, v.violation.map(|v| v.kind)), ((10.0, 10.0), Some(ViolationKind::Teleport)));
|
|
183
|
+
assert_eq!(w.strikes(10_100), 2);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#[test]
|
|
187
|
+
fn the_bounds_pull_a_move_back_in_and_strikes_clear_after_a_quiet_window() {
|
|
188
|
+
let mut w = Warden::at((99.0, 50.0), 0);
|
|
189
|
+
let v = w.check(&rules(), (101.0, 50.0), 1_000);
|
|
190
|
+
assert_eq!((v.at, v.violation.map(|v| v.kind)), ((100.0, 50.0), Some(ViolationKind::Bounds)));
|
|
191
|
+
assert_eq!(w.strikes(1_000), 1);
|
|
192
|
+
assert_eq!(w.strikes(1_000 + STRIKE_WINDOW_MS), 0);
|
|
193
|
+
w.place((5.0, 5.0), 20_000);
|
|
194
|
+
assert_eq!(w.check(&rules(), (6.0, 5.0), 20_200).violation, None);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
//! Authoritative scoring: server-held score books, typed win conditions, and
|
|
2
|
+
//! match summaries. The hub owns the only write path (a client never
|
|
3
|
+
//! submits its own score), then publishes the summary on a topic or
|
|
4
|
+
//! as an event.
|
|
5
|
+
|
|
6
|
+
use serde::{Deserialize, Serialize};
|
|
7
|
+
use serde_json::{Value, json};
|
|
8
|
+
use std::collections::BTreeMap;
|
|
9
|
+
|
|
10
|
+
/// When is the match decided?
|
|
11
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
12
|
+
pub enum WinCondition {
|
|
13
|
+
/// First to reach `target` points.
|
|
14
|
+
ScoreReached { target: i64 },
|
|
15
|
+
/// Highest score when round `final_round` completes.
|
|
16
|
+
BestOfRounds { final_round: u32 },
|
|
17
|
+
/// Last active participant standing (driven by forfeits/eliminations).
|
|
18
|
+
LastStanding,
|
|
19
|
+
/// Survive until the timer: highest score at `ends_at_ms`.
|
|
20
|
+
TimeLimit { ends_at_ms: u64 },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
24
|
+
pub struct Standing {
|
|
25
|
+
pub actor_id: String,
|
|
26
|
+
pub score: i64,
|
|
27
|
+
pub rank: u32,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// The published result of a decided match.
|
|
31
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
32
|
+
pub struct MatchSummary {
|
|
33
|
+
pub winner_id: Option<String>,
|
|
34
|
+
pub standings: Vec<Standing>,
|
|
35
|
+
pub rounds_played: u32,
|
|
36
|
+
pub decided_by: String,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
40
|
+
pub struct ScoreBook {
|
|
41
|
+
pub scores: BTreeMap<String, i64>,
|
|
42
|
+
/// Actors still in the running (forfeits/eliminations remove).
|
|
43
|
+
pub active: Vec<String>,
|
|
44
|
+
pub decided: Option<MatchSummary>,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
impl ScoreBook {
|
|
48
|
+
pub fn new(actors: impl IntoIterator<Item = String>) -> Self {
|
|
49
|
+
let active: Vec<String> = actors.into_iter().collect();
|
|
50
|
+
let scores = active.iter().map(|id| (id.clone(), 0)).collect();
|
|
51
|
+
Self { scores, active, decided: None }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Add points (the hub calls this from validated moves only).
|
|
55
|
+
pub fn award(&mut self, actor_id: &str, points: i64) -> i64 {
|
|
56
|
+
let entry = self.scores.entry(actor_id.to_string()).or_insert(0);
|
|
57
|
+
*entry += points;
|
|
58
|
+
*entry
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
pub fn score(&self, actor_id: &str) -> i64 {
|
|
62
|
+
self.scores.get(actor_id).copied().unwrap_or(0)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Remove an actor from contention (death/forfeit/disconnect).
|
|
66
|
+
pub fn eliminate(&mut self, actor_id: &str) {
|
|
67
|
+
self.active.retain(|id| id != actor_id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Standings ordered by score desc (stable; rank is 1-based, ties share
|
|
71
|
+
/// the earlier rank's position order but distinct ranks — simple v1).
|
|
72
|
+
pub fn standings(&self) -> Vec<Standing> {
|
|
73
|
+
let mut rows: Vec<(String, i64)> = self.scores.iter().map(|(id, s)| (id.clone(), *s)).collect();
|
|
74
|
+
rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
|
75
|
+
rows.into_iter().enumerate().map(|(index, (actor_id, score))| Standing { actor_id, score, rank: index as u32 + 1 }).collect()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Evaluate the condition; the first decision sticks (idempotent after).
|
|
79
|
+
pub fn evaluate(&mut self, condition: &WinCondition, rounds_played: u32, now_ms: u64) -> Option<&MatchSummary> {
|
|
80
|
+
if self.decided.is_some() {
|
|
81
|
+
return self.decided.as_ref();
|
|
82
|
+
}
|
|
83
|
+
let (decided, decided_by) = match condition {
|
|
84
|
+
WinCondition::ScoreReached { target } => (self.scores.values().any(|s| *s >= *target), "score_reached"),
|
|
85
|
+
WinCondition::BestOfRounds { final_round } => (rounds_played >= *final_round, "rounds_complete"),
|
|
86
|
+
WinCondition::LastStanding => (self.active.len() <= 1, "last_standing"),
|
|
87
|
+
WinCondition::TimeLimit { ends_at_ms } => (now_ms >= *ends_at_ms, "time_limit"),
|
|
88
|
+
};
|
|
89
|
+
if !decided {
|
|
90
|
+
return None;
|
|
91
|
+
}
|
|
92
|
+
let standings = self.standings();
|
|
93
|
+
let winner_id = match condition {
|
|
94
|
+
// Standing-based conditions crown the survivor; score-based the top.
|
|
95
|
+
WinCondition::LastStanding => self.active.first().cloned(),
|
|
96
|
+
_ => standings.first().map(|s| s.actor_id.clone()),
|
|
97
|
+
};
|
|
98
|
+
self.decided = Some(MatchSummary { winner_id, standings, rounds_played, decided_by: decided_by.to_string() });
|
|
99
|
+
self.decided.as_ref()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// The summary as the JSON shape clients/events consume.
|
|
104
|
+
pub fn summary_json(summary: &MatchSummary) -> Value {
|
|
105
|
+
json!({
|
|
106
|
+
"winnerId": summary.winner_id,
|
|
107
|
+
"standings": summary.standings.iter().map(|s| json!({
|
|
108
|
+
"actorId": s.actor_id, "score": s.score, "rank": s.rank,
|
|
109
|
+
})).collect::<Vec<_>>(),
|
|
110
|
+
"roundsPlayed": summary.rounds_played,
|
|
111
|
+
"decidedBy": summary.decided_by,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#[cfg(test)]
|
|
116
|
+
mod tests {
|
|
117
|
+
use super::*;
|
|
118
|
+
|
|
119
|
+
fn book() -> ScoreBook {
|
|
120
|
+
ScoreBook::new(["a".to_string(), "b".to_string(), "c".to_string()])
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#[test]
|
|
124
|
+
fn scoring_and_standings() {
|
|
125
|
+
let mut scores = book();
|
|
126
|
+
scores.award("b", 5);
|
|
127
|
+
scores.award("a", 3);
|
|
128
|
+
scores.award("b", 2);
|
|
129
|
+
let standings = scores.standings();
|
|
130
|
+
assert_eq!(standings[0].actor_id, "b");
|
|
131
|
+
assert_eq!(standings[0].score, 7);
|
|
132
|
+
assert_eq!(standings[0].rank, 1);
|
|
133
|
+
assert_eq!(standings[2].actor_id, "c");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#[test]
|
|
137
|
+
fn score_reached_decides_once() {
|
|
138
|
+
let mut scores = book();
|
|
139
|
+
scores.award("a", 9);
|
|
140
|
+
assert!(scores.evaluate(&WinCondition::ScoreReached { target: 10 }, 1, 0).is_none());
|
|
141
|
+
scores.award("a", 1);
|
|
142
|
+
let summary = scores.evaluate(&WinCondition::ScoreReached { target: 10 }, 1, 0).cloned().unwrap();
|
|
143
|
+
assert_eq!(summary.winner_id.as_deref(), Some("a"));
|
|
144
|
+
assert_eq!(summary.decided_by, "score_reached");
|
|
145
|
+
// Later evaluations return the same decision even if scores move.
|
|
146
|
+
scores.award("b", 99);
|
|
147
|
+
let again = scores.evaluate(&WinCondition::ScoreReached { target: 10 }, 2, 0).unwrap();
|
|
148
|
+
assert_eq!(again.winner_id.as_deref(), Some("a"));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#[test]
|
|
152
|
+
fn last_standing_and_rounds_and_time() {
|
|
153
|
+
let mut scores = book();
|
|
154
|
+
scores.eliminate("a");
|
|
155
|
+
assert!(scores.evaluate(&WinCondition::LastStanding, 1, 0).is_none());
|
|
156
|
+
scores.eliminate("c");
|
|
157
|
+
let summary = scores.evaluate(&WinCondition::LastStanding, 1, 0).unwrap();
|
|
158
|
+
assert_eq!(summary.winner_id.as_deref(), Some("b"));
|
|
159
|
+
assert_eq!(summary.decided_by, "last_standing");
|
|
160
|
+
|
|
161
|
+
let mut rounds = book();
|
|
162
|
+
rounds.award("c", 4);
|
|
163
|
+
assert!(rounds.evaluate(&WinCondition::BestOfRounds { final_round: 3 }, 2, 0).is_none());
|
|
164
|
+
let summary = rounds.evaluate(&WinCondition::BestOfRounds { final_round: 3 }, 3, 0).unwrap();
|
|
165
|
+
assert_eq!(summary.winner_id.as_deref(), Some("c"));
|
|
166
|
+
|
|
167
|
+
let mut timed = book();
|
|
168
|
+
timed.award("a", 1);
|
|
169
|
+
assert!(timed.evaluate(&WinCondition::TimeLimit { ends_at_ms: 60_000 }, 5, 59_999).is_none());
|
|
170
|
+
assert!(timed.evaluate(&WinCondition::TimeLimit { ends_at_ms: 60_000 }, 5, 60_000).is_some());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#[test]
|
|
174
|
+
fn summary_json_shape() {
|
|
175
|
+
let mut scores = book();
|
|
176
|
+
scores.award("a", 10);
|
|
177
|
+
let summary = scores.evaluate(&WinCondition::ScoreReached { target: 10 }, 2, 0).unwrap();
|
|
178
|
+
let v = summary_json(summary);
|
|
179
|
+
assert_eq!(v["winnerId"], "a");
|
|
180
|
+
assert_eq!(v["standings"][0]["rank"], 1);
|
|
181
|
+
assert_eq!(v["roundsPlayed"], 2);
|
|
182
|
+
}
|
|
183
|
+
}
|