@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,878 @@
|
|
|
1
|
+
//! Space players hold, for two node types from one crate. The hub reads where players are with
|
|
2
|
+
//! the node API (`ckx_sdk::kit::presence`, scope `world.read`): a client never says it is on a
|
|
3
|
+
//! point or inside the circle.
|
|
4
|
+
//!
|
|
5
|
+
//! - **`territory`**, keyed by its map: control points that the factions capture by having more
|
|
6
|
+
//! members standing on them than any other faction has, with capture progress that decays when
|
|
7
|
+
//! nobody presses it, income for each point held, and a winning score. The shape of a
|
|
8
|
+
//! conquest mode, a guild war, a king-of-the-hill. Endpoints: `join { faction }` (once; a
|
|
9
|
+
//! player keeps their faction), `reset` (developers: a new war), `state` (anyone). Topic
|
|
10
|
+
//! `territory`: `snapshot` messages when a point moves, `event` messages (`type`: `captured`,
|
|
11
|
+
//! `income`, `won`); a point changing hands is also realtime event 96 at the point.
|
|
12
|
+
//! - **`zone`**, keyed by its match: a circle that holds, then shrinks, phase by phase
|
|
13
|
+
//! (`ckx_sdk::kit::sim::zones`), and wears down every player outside it until one is left. The
|
|
14
|
+
//! battle royale's circle; pair it with an `arena` for the fighting. Endpoints: `join`, `start`
|
|
15
|
+
//! (the first to join, or a developer), `state` (anyone). Topic `zone`: `snapshot` messages
|
|
16
|
+
//! each scan, `event` messages (`type`: `warning`, `shrinking`, `settled`, `final`,
|
|
17
|
+
//! `eliminated`, `won`); the circle's moves are also realtime event 98 at its centre. It sees
|
|
18
|
+
//! players within about ten chunks of its centre (nine probes of the node API's largest
|
|
19
|
+
//! radius), so its first circle is at most that.
|
|
20
|
+
//!
|
|
21
|
+
//! A type runs as the one its seed names (`{"mode": "zone", ...}`), or as the one its name is:
|
|
22
|
+
//! name a type something else and give it a seed with its `mode`. Both speak the kit protocol
|
|
23
|
+
//! (`ckx_sdk::kit`): `snapshot { topic }` answers each topic; changes may carry an `action_id`;
|
|
24
|
+
//! refusals carry `kit:<code>:` codes.
|
|
25
|
+
|
|
26
|
+
use std::collections::BTreeMap;
|
|
27
|
+
|
|
28
|
+
use ckx_sdk::kit::presence::{self, Seen};
|
|
29
|
+
use ckx_sdk::kit::sim::zones::{ShrinkPhase, ShrinkingZone, ZoneEvent};
|
|
30
|
+
use ckx_sdk::kit::{self, Publisher, Receipts, codes, wire};
|
|
31
|
+
use ckx_sdk::prelude::*;
|
|
32
|
+
use serde_json::{Value, json};
|
|
33
|
+
|
|
34
|
+
pub const TERRITORY_TOPIC: &str = "territory";
|
|
35
|
+
pub const ZONE_TOPIC: &str = "zone";
|
|
36
|
+
const SCAN: &str = "scan";
|
|
37
|
+
const INCOME: &str = "income";
|
|
38
|
+
/// The node API's largest radius, and the spacing of the zone's probes (each sees 7 chunks
|
|
39
|
+
/// across).
|
|
40
|
+
const PROBE_RADIUS: u8 = 3;
|
|
41
|
+
const PROBE_STEP: i64 = 7;
|
|
42
|
+
|
|
43
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
|
44
|
+
#[serde(rename_all = "snake_case")]
|
|
45
|
+
pub enum Mode {
|
|
46
|
+
Territory,
|
|
47
|
+
Zone,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// The type this instance runs as: its seed's `mode`, or its name.
|
|
51
|
+
pub fn mode_of(node_type: &str, seed: &[u8]) -> Result<Mode> {
|
|
52
|
+
#[derive(Deserialize)]
|
|
53
|
+
struct Named {
|
|
54
|
+
#[serde(default)]
|
|
55
|
+
mode: Option<Mode>,
|
|
56
|
+
}
|
|
57
|
+
if !seed.is_empty() {
|
|
58
|
+
let named: Named = serde_json::from_slice(seed).map_err(|e| Error::new(format!("seed is not a JSON config: {e}")))?;
|
|
59
|
+
if let Some(mode) = named.mode {
|
|
60
|
+
return Ok(mode);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
match node_type {
|
|
64
|
+
"territory" => Ok(Mode::Territory),
|
|
65
|
+
"zone" => Ok(Mode::Zone),
|
|
66
|
+
other => Err(Error::new(format!(
|
|
67
|
+
"the territory crate runs a `territory` or a `zone`; give `{other}` a seed with its mode: {{\"mode\": \"zone\"}}"
|
|
68
|
+
))),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fn config<T: serde::de::DeserializeOwned + Default>(seed: &[u8]) -> Result<T> {
|
|
73
|
+
if seed.is_empty() {
|
|
74
|
+
return Ok(T::default());
|
|
75
|
+
}
|
|
76
|
+
serde_json::from_slice(seed).map_err(|e| Error::new(format!("seed is not a JSON config: {e}")))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fn with_type(kind: &str, extra: Value) -> Value {
|
|
80
|
+
let mut e = json!({ "type": kind });
|
|
81
|
+
if let (Some(e), Some(x)) = (e.as_object_mut(), extra.as_object()) {
|
|
82
|
+
e.extend(x.clone());
|
|
83
|
+
}
|
|
84
|
+
e
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- territory ----
|
|
88
|
+
|
|
89
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
90
|
+
pub struct Point {
|
|
91
|
+
pub id: String,
|
|
92
|
+
pub x: i64,
|
|
93
|
+
#[serde(default)]
|
|
94
|
+
pub y: i64,
|
|
95
|
+
pub z: i64,
|
|
96
|
+
/// Chunks around it that count as on it (at most 3).
|
|
97
|
+
#[serde(default = "default_radius")]
|
|
98
|
+
pub radius: u8,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fn default_radius() -> u8 {
|
|
102
|
+
1
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
106
|
+
#[serde(default)]
|
|
107
|
+
pub struct TerritoryConfig {
|
|
108
|
+
pub points: Vec<Point>,
|
|
109
|
+
pub factions: Vec<String>,
|
|
110
|
+
/// Capture progress a second for each member more than the next faction has there.
|
|
111
|
+
pub capture_per_sec: f64,
|
|
112
|
+
/// Progress lost a second when nobody presses the capture.
|
|
113
|
+
pub decay_per_sec: f64,
|
|
114
|
+
pub scan_ms: u64,
|
|
115
|
+
pub income_ms: u64,
|
|
116
|
+
pub income_per_point: i64,
|
|
117
|
+
/// The score that wins the war; none for a war without end.
|
|
118
|
+
pub win_score: Option<i64>,
|
|
119
|
+
pub members_max: usize,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
impl Default for TerritoryConfig {
|
|
123
|
+
fn default() -> Self {
|
|
124
|
+
let point = |id: &str, x: i64| Point { id: id.into(), x, y: 0, z: 0, radius: 1 };
|
|
125
|
+
Self {
|
|
126
|
+
points: vec![point("mill", -8), point("bridge", 0), point("tower", 8)],
|
|
127
|
+
factions: vec!["red".into(), "blue".into()],
|
|
128
|
+
capture_per_sec: 0.1,
|
|
129
|
+
decay_per_sec: 0.05,
|
|
130
|
+
scan_ms: 2_000,
|
|
131
|
+
income_ms: 60_000,
|
|
132
|
+
income_per_point: 10,
|
|
133
|
+
win_score: Some(500),
|
|
134
|
+
members_max: 500,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
|
140
|
+
pub struct PointState {
|
|
141
|
+
pub id: String,
|
|
142
|
+
pub owner: Option<String>,
|
|
143
|
+
/// The faction pressing a capture, and how far it is, 0 to 1.
|
|
144
|
+
pub capturing: Option<String>,
|
|
145
|
+
pub progress: f64,
|
|
146
|
+
/// Members on the point at the last scan, by faction.
|
|
147
|
+
pub present: BTreeMap<String, u32>,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
151
|
+
#[serde(rename_all = "snake_case")]
|
|
152
|
+
pub enum WarPhase {
|
|
153
|
+
#[default]
|
|
154
|
+
Open,
|
|
155
|
+
Won,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// The war as everyone sees it.
|
|
159
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
160
|
+
pub struct TerritoryView {
|
|
161
|
+
pub phase: WarPhase,
|
|
162
|
+
pub points: Vec<PointState>,
|
|
163
|
+
pub scores: BTreeMap<String, i64>,
|
|
164
|
+
pub members: BTreeMap<String, u32>,
|
|
165
|
+
pub winner: Option<String>,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#[derive(Deserialize)]
|
|
169
|
+
struct Join {
|
|
170
|
+
faction: String,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
174
|
+
pub struct Territory {
|
|
175
|
+
pub config: TerritoryConfig,
|
|
176
|
+
pub phase: WarPhase,
|
|
177
|
+
pub members: BTreeMap<u64, String>,
|
|
178
|
+
pub points: Vec<PointState>,
|
|
179
|
+
pub scores: BTreeMap<String, i64>,
|
|
180
|
+
pub winner: Option<String>,
|
|
181
|
+
pub armed: bool,
|
|
182
|
+
#[serde(skip)]
|
|
183
|
+
pub events: Vec<Value>,
|
|
184
|
+
#[serde(skip)]
|
|
185
|
+
pub moved: bool,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
impl Territory {
|
|
189
|
+
pub fn new(config: TerritoryConfig) -> Self {
|
|
190
|
+
let mut t = Self { config, ..Self::default() };
|
|
191
|
+
t.reset();
|
|
192
|
+
t
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
pub fn reset(&mut self) {
|
|
196
|
+
self.phase = WarPhase::Open;
|
|
197
|
+
self.winner = None;
|
|
198
|
+
self.points = self.config.points.iter().map(|p| PointState { id: p.id.clone(), ..PointState::default() }).collect();
|
|
199
|
+
self.scores = self.config.factions.iter().map(|f| (f.clone(), 0)).collect();
|
|
200
|
+
self.moved = true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
pub fn view(&self) -> TerritoryView {
|
|
204
|
+
let mut members: BTreeMap<String, u32> = self.config.factions.iter().map(|f| (f.clone(), 0)).collect();
|
|
205
|
+
for f in self.members.values() {
|
|
206
|
+
*members.entry(f.clone()).or_insert(0) += 1;
|
|
207
|
+
}
|
|
208
|
+
TerritoryView { phase: self.phase, points: self.points.clone(), scores: self.scores.clone(), members, winner: self.winner.clone() }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
pub fn join(&mut self, player: u64, faction: &str) -> Result<()> {
|
|
212
|
+
if !self.config.factions.iter().any(|f| f == faction) {
|
|
213
|
+
return Err(kit::error(codes::BAD_REQUEST, format!("a faction is one of {}", self.config.factions.join(", "))));
|
|
214
|
+
}
|
|
215
|
+
match self.members.get(&player) {
|
|
216
|
+
Some(f) if f == faction => Ok(()),
|
|
217
|
+
Some(f) => Err(kit::error(codes::CONFLICT, format!("you fight for {f}"))),
|
|
218
|
+
None if self.members.len() >= self.config.members_max => Err(kit::error(codes::FULL, "the war has all the members it takes")),
|
|
219
|
+
None => {
|
|
220
|
+
self.members.insert(player, faction.to_owned());
|
|
221
|
+
self.moved = true;
|
|
222
|
+
Ok(())
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/// One scan: `seen[i]` is who stands on point `i`. Moves the captures over `dt_ms`.
|
|
228
|
+
pub fn scan(&mut self, seen: &[Vec<Seen>], dt_ms: u64) {
|
|
229
|
+
if self.phase != WarPhase::Open {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
let dt = dt_ms as f64 / 1000.0;
|
|
233
|
+
for (i, point) in self.points.iter_mut().enumerate() {
|
|
234
|
+
let mut present: BTreeMap<String, u32> = BTreeMap::new();
|
|
235
|
+
for s in seen.get(i).map(Vec::as_slice).unwrap_or_default() {
|
|
236
|
+
if let Some(f) = self.members.get(&s.player) {
|
|
237
|
+
*present.entry(f.clone()).or_insert(0) += 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
let mut counts: Vec<(&String, u32)> = present.iter().map(|(f, n)| (f, *n)).collect();
|
|
241
|
+
counts.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
|
|
242
|
+
let lead = counts.first().map(|(f, n)| ((*f).clone(), n - counts.get(1).map_or(0, |c| c.1)));
|
|
243
|
+
let before = (point.owner.clone(), point.capturing.clone(), point.progress);
|
|
244
|
+
match lead {
|
|
245
|
+
Some((leader, margin)) if margin > 0 && point.owner.as_ref() != Some(&leader) => {
|
|
246
|
+
if point.capturing.as_ref() != Some(&leader) {
|
|
247
|
+
point.capturing = Some(leader.clone());
|
|
248
|
+
point.progress = 0.0;
|
|
249
|
+
}
|
|
250
|
+
point.progress += self.config.capture_per_sec * f64::from(margin) * dt;
|
|
251
|
+
if point.progress >= 1.0 {
|
|
252
|
+
let previous = point.owner.replace(leader.clone());
|
|
253
|
+
point.capturing = None;
|
|
254
|
+
point.progress = 0.0;
|
|
255
|
+
self.events.push(with_type("captured", json!({ "point": point.id, "owner": leader, "previous": previous })));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Contested: the capture holds where it is.
|
|
259
|
+
Some((_, 0)) => {}
|
|
260
|
+
_ => {
|
|
261
|
+
point.progress = (point.progress - self.config.decay_per_sec * dt).max(0.0);
|
|
262
|
+
if point.progress == 0.0 {
|
|
263
|
+
point.capturing = None;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if before != (point.owner.clone(), point.capturing.clone(), point.progress) || point.present != present {
|
|
268
|
+
self.moved = true;
|
|
269
|
+
}
|
|
270
|
+
point.present = present;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/// Income for each point held; the first faction to the winning score wins.
|
|
275
|
+
pub fn income(&mut self) {
|
|
276
|
+
if self.phase != WarPhase::Open {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
let mut paid: BTreeMap<String, i64> = BTreeMap::new();
|
|
280
|
+
for p in &self.points {
|
|
281
|
+
if let Some(owner) = &p.owner {
|
|
282
|
+
*paid.entry(owner.clone()).or_insert(0) += self.config.income_per_point;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if paid.is_empty() {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
for (faction, gold) in &paid {
|
|
289
|
+
*self.scores.entry(faction.clone()).or_insert(0) += gold;
|
|
290
|
+
}
|
|
291
|
+
self.events.push(with_type("income", json!({ "paid": paid })));
|
|
292
|
+
self.moved = true;
|
|
293
|
+
if let Some(target) = self.config.win_score
|
|
294
|
+
&& let Some((faction, _)) = self.scores.iter().filter(|(_, s)| **s >= target).max_by_key(|(_, s)| **s)
|
|
295
|
+
{
|
|
296
|
+
self.phase = WarPhase::Won;
|
|
297
|
+
self.winner = Some(faction.clone());
|
|
298
|
+
self.events.push(with_type("won", json!({ "faction": faction })));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
304
|
+
pub struct TerritoryHub {
|
|
305
|
+
#[serde(flatten)]
|
|
306
|
+
pub war: Territory,
|
|
307
|
+
#[serde(default)]
|
|
308
|
+
pub kit: Publisher,
|
|
309
|
+
#[serde(default)]
|
|
310
|
+
pub receipts: Receipts,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
impl TerritoryHub {
|
|
314
|
+
fn act(&mut self, ctx: &Ctx, call: &Call<'_>) -> Result<Vec<u8>> {
|
|
315
|
+
let out = match call.method {
|
|
316
|
+
"join" => {
|
|
317
|
+
let j: Join = kit::args(call)?;
|
|
318
|
+
self.war.join(call.player()?, &j.faction)?;
|
|
319
|
+
self.arm(ctx)?;
|
|
320
|
+
encode(&true)
|
|
321
|
+
}
|
|
322
|
+
"reset" => {
|
|
323
|
+
call.developer().map_err(|_| kit::error(codes::FORBIDDEN, "developers start a new war"))?;
|
|
324
|
+
self.war.reset();
|
|
325
|
+
self.arm(ctx)?;
|
|
326
|
+
encode(&true)
|
|
327
|
+
}
|
|
328
|
+
other => return Err(Error::unknown_method(other)),
|
|
329
|
+
};
|
|
330
|
+
self.flush(ctx)?;
|
|
331
|
+
out
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
fn arm(&mut self, ctx: &Ctx) -> Result<()> {
|
|
335
|
+
if !self.war.armed {
|
|
336
|
+
ctx.timer_every(SCAN, self.war.config.scan_ms.max(500))?;
|
|
337
|
+
ctx.timer_every(INCOME, self.war.config.income_ms.max(1_000))?;
|
|
338
|
+
self.war.armed = true;
|
|
339
|
+
}
|
|
340
|
+
Ok(())
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
fn flush(&mut self, ctx: &Ctx) -> Result<()> {
|
|
344
|
+
for e in std::mem::take(&mut self.war.events) {
|
|
345
|
+
self.kit.event(ctx, TERRITORY_TOPIC, &e)?;
|
|
346
|
+
if e["type"] == "captured" {
|
|
347
|
+
self.realtime(ctx, &e);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if std::mem::take(&mut self.war.moved) {
|
|
351
|
+
self.kit.snapshot(ctx, TERRITORY_TOPIC, &self.war.view())?;
|
|
352
|
+
}
|
|
353
|
+
Ok(())
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
fn realtime(&self, ctx: &Ctx, e: &Value) {
|
|
357
|
+
let Some(point) = self.war.config.points.iter().find(|p| e["point"] == p.id.as_str()) else { return };
|
|
358
|
+
let body = wire::ControlPointChange {
|
|
359
|
+
point_id: point.id.clone(),
|
|
360
|
+
owner: e["owner"].as_str().unwrap_or_default().into(),
|
|
361
|
+
previous_owner: e["previous"].as_str().unwrap_or_default().into(),
|
|
362
|
+
progress: 1.0,
|
|
363
|
+
};
|
|
364
|
+
let actor = actor_id(&format!("point:{}", point.id));
|
|
365
|
+
if let Err(Error(why)) = wire::emit_event(ctx, ChunkPos::new(point.x, point.y, point.z), 4, &actor, wire::CONTROL_POINT, &body) {
|
|
366
|
+
ctx.log(Level::Warn, &format!("a capture's realtime event did not go out: {why}"));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
fn on_scan(&mut self, ctx: &Ctx) -> Result<()> {
|
|
371
|
+
let mut seen = Vec::with_capacity(self.war.config.points.len());
|
|
372
|
+
for p in &self.war.config.points {
|
|
373
|
+
match presence::nearby(ctx, ChunkPos::new(p.x, p.y, p.z), p.radius.min(PROBE_RADIUS)) {
|
|
374
|
+
Ok(s) => seen.push(s),
|
|
375
|
+
Err(e) => {
|
|
376
|
+
ctx.log(Level::Warn, &format!("point {} was not scanned: {}", p.id, e.message));
|
|
377
|
+
return Ok(());
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
self.war.scan(&seen, self.war.config.scan_ms);
|
|
382
|
+
self.flush(ctx)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ---- zone ----
|
|
387
|
+
|
|
388
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
389
|
+
#[serde(default)]
|
|
390
|
+
pub struct ZoneConfig {
|
|
391
|
+
/// The circle's centre, in chunks.
|
|
392
|
+
pub center: (f64, f64),
|
|
393
|
+
pub y: i64,
|
|
394
|
+
/// The first circle's radius, in chunks (at most about ten).
|
|
395
|
+
pub radius: f64,
|
|
396
|
+
pub phases: Vec<ShrinkPhase>,
|
|
397
|
+
pub hp: i64,
|
|
398
|
+
/// What a second outside the circle costs.
|
|
399
|
+
pub damage_per_sec: i64,
|
|
400
|
+
pub scan_ms: u64,
|
|
401
|
+
pub players_max: usize,
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
impl Default for ZoneConfig {
|
|
405
|
+
fn default() -> Self {
|
|
406
|
+
Self {
|
|
407
|
+
center: (0.5, 0.5),
|
|
408
|
+
y: 0,
|
|
409
|
+
radius: 10.0,
|
|
410
|
+
phases: vec![
|
|
411
|
+
ShrinkPhase { radius: 6.0, hold_ms: 60_000, shrink_ms: 30_000 },
|
|
412
|
+
ShrinkPhase { radius: 3.0, hold_ms: 45_000, shrink_ms: 20_000 },
|
|
413
|
+
ShrinkPhase { radius: 1.0, hold_ms: 30_000, shrink_ms: 15_000 },
|
|
414
|
+
],
|
|
415
|
+
hp: 100,
|
|
416
|
+
damage_per_sec: 5,
|
|
417
|
+
scan_ms: 1_000,
|
|
418
|
+
players_max: 64,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
424
|
+
#[serde(rename_all = "snake_case")]
|
|
425
|
+
pub enum MatchPhase {
|
|
426
|
+
#[default]
|
|
427
|
+
Lobby,
|
|
428
|
+
Running,
|
|
429
|
+
Finished,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
433
|
+
pub struct Contender {
|
|
434
|
+
pub player: u64,
|
|
435
|
+
pub hp: i64,
|
|
436
|
+
/// Outside the circle at the last scan (or nowhere the probes saw).
|
|
437
|
+
pub outside: bool,
|
|
438
|
+
/// 1 for the winner; set as each player is eliminated.
|
|
439
|
+
pub place: Option<u32>,
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/// The match as everyone sees it.
|
|
443
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
444
|
+
pub struct ZoneView {
|
|
445
|
+
pub phase: MatchPhase,
|
|
446
|
+
pub center: (f64, f64),
|
|
447
|
+
pub radius: f64,
|
|
448
|
+
/// The phase under way, and the radius it shrinks to.
|
|
449
|
+
pub stage: Option<usize>,
|
|
450
|
+
pub next_radius: Option<f64>,
|
|
451
|
+
pub players: Vec<Contender>,
|
|
452
|
+
pub alive: u32,
|
|
453
|
+
pub winner: Option<u64>,
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
457
|
+
pub struct Zone {
|
|
458
|
+
pub config: ZoneConfig,
|
|
459
|
+
pub phase: MatchPhase,
|
|
460
|
+
pub circle: ShrinkingZone,
|
|
461
|
+
pub players: Vec<Contender>,
|
|
462
|
+
pub stage: Option<usize>,
|
|
463
|
+
pub winner: Option<u64>,
|
|
464
|
+
#[serde(skip)]
|
|
465
|
+
pub events: Vec<Value>,
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
impl Zone {
|
|
469
|
+
pub fn view(&self, now: u64) -> ZoneView {
|
|
470
|
+
let radius = if self.phase == MatchPhase::Lobby { self.config.radius } else { f64::from(self.circle.radius(now)) };
|
|
471
|
+
ZoneView {
|
|
472
|
+
phase: self.phase,
|
|
473
|
+
center: self.config.center,
|
|
474
|
+
radius,
|
|
475
|
+
stage: self.stage,
|
|
476
|
+
next_radius: self.stage.and_then(|s| self.config.phases.get(s)).map(|p| f64::from(p.radius)),
|
|
477
|
+
players: self.players.clone(),
|
|
478
|
+
alive: self.alive() as u32,
|
|
479
|
+
winner: self.winner,
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
fn alive(&self) -> usize {
|
|
484
|
+
self.players.iter().filter(|p| p.place.is_none()).count()
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
pub fn join(&mut self, player: u64) -> Result<()> {
|
|
488
|
+
if self.phase != MatchPhase::Lobby {
|
|
489
|
+
return Err(kit::error(codes::STARTED, "the match is under way"));
|
|
490
|
+
}
|
|
491
|
+
if self.players.iter().any(|p| p.player == player) {
|
|
492
|
+
return Ok(());
|
|
493
|
+
}
|
|
494
|
+
if self.players.len() >= self.config.players_max {
|
|
495
|
+
return Err(kit::error(codes::FULL, "the match is full"));
|
|
496
|
+
}
|
|
497
|
+
self.players.push(Contender { player, hp: self.config.hp, outside: false, place: None });
|
|
498
|
+
Ok(())
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
pub fn start(&mut self, now: u64) -> Result<()> {
|
|
502
|
+
if self.phase != MatchPhase::Lobby {
|
|
503
|
+
return Err(kit::error(codes::STARTED, "the match is under way"));
|
|
504
|
+
}
|
|
505
|
+
if self.players.len() < 2 {
|
|
506
|
+
return Err(kit::error(codes::NOT_READY, "a match takes two"));
|
|
507
|
+
}
|
|
508
|
+
let c = &self.config;
|
|
509
|
+
self.circle = ShrinkingZone::start((c.center.0 as f32, c.center.1 as f32), c.radius as f32, c.phases.clone(), now);
|
|
510
|
+
self.phase = MatchPhase::Running;
|
|
511
|
+
Ok(())
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/// Where the probes go: the centre, and a ring around it when the circle is wider than one.
|
|
515
|
+
pub fn probes(&self) -> Vec<ChunkPos> {
|
|
516
|
+
let (cx, cz) = (self.config.center.0.floor() as i64, self.config.center.1.floor() as i64);
|
|
517
|
+
let reach: &[i64] = if self.config.radius > f64::from(PROBE_RADIUS) + 0.5 { &[-PROBE_STEP, 0, PROBE_STEP] } else { &[0] };
|
|
518
|
+
let mut out = Vec::new();
|
|
519
|
+
for dx in reach {
|
|
520
|
+
for dz in reach {
|
|
521
|
+
out.push(ChunkPos::new(cx + dx, self.config.y, cz + dz));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
out
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/// One scan: who the probes saw, `dt_ms` since the last.
|
|
528
|
+
pub fn scan(&mut self, seen: &[Seen], now: u64, dt_ms: u64) {
|
|
529
|
+
if self.phase != MatchPhase::Running {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
for e in self.circle.poll(now) {
|
|
533
|
+
let (kind, body) = match e {
|
|
534
|
+
ZoneEvent::Warning { phase, to_radius, shrink_starts_at_ms } => {
|
|
535
|
+
self.stage = Some(phase);
|
|
536
|
+
("warning", json!({ "phase": phase, "to_radius": to_radius, "shrinks_at": shrink_starts_at_ms }))
|
|
537
|
+
}
|
|
538
|
+
ZoneEvent::Shrinking { phase, to_radius } => ("shrinking", json!({ "phase": phase, "to_radius": to_radius })),
|
|
539
|
+
ZoneEvent::Settled { phase, radius } => ("settled", json!({ "phase": phase, "radius": radius })),
|
|
540
|
+
ZoneEvent::Final { radius } => ("final", json!({ "radius": radius })),
|
|
541
|
+
};
|
|
542
|
+
self.events.push(with_type(kind, body));
|
|
543
|
+
}
|
|
544
|
+
let cost = (self.config.damage_per_sec * dt_ms as i64 + 999) / 1000;
|
|
545
|
+
let before: BTreeMap<u64, i64> = self.players.iter().map(|p| (p.player, p.hp)).collect();
|
|
546
|
+
let mut fallen = Vec::new();
|
|
547
|
+
for p in self.players.iter_mut().filter(|p| p.place.is_none()) {
|
|
548
|
+
let inside = seen.iter().find(|s| s.player == p.player).is_some_and(|s| self.circle.contains((s.x as f32, s.z as f32), now));
|
|
549
|
+
p.outside = !inside;
|
|
550
|
+
if !inside {
|
|
551
|
+
p.hp = (p.hp - cost).max(0);
|
|
552
|
+
if p.hp == 0 {
|
|
553
|
+
fallen.push(p.player);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
let alive_before = self.alive();
|
|
558
|
+
if fallen.len() == alive_before {
|
|
559
|
+
// Everyone left fell at once: the one who had the most stands.
|
|
560
|
+
let best = fallen.iter().map(|p| before[p]).max().unwrap_or(0);
|
|
561
|
+
let top: Vec<u64> = fallen.iter().copied().filter(|p| before[p] == best).collect();
|
|
562
|
+
if top.len() == 1 {
|
|
563
|
+
fallen.retain(|p| *p != top[0]);
|
|
564
|
+
if let Some(c) = self.players.iter_mut().find(|c| c.player == top[0]) {
|
|
565
|
+
c.hp = 1;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
let mut place = alive_before as u32;
|
|
570
|
+
for player in fallen {
|
|
571
|
+
if let Some(c) = self.players.iter_mut().find(|c| c.player == player) {
|
|
572
|
+
c.place = Some(place);
|
|
573
|
+
self.events.push(with_type("eliminated", json!({ "player": player, "place": place })));
|
|
574
|
+
place -= 1;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if self.alive() <= 1 {
|
|
578
|
+
self.phase = MatchPhase::Finished;
|
|
579
|
+
self.winner = self.players.iter().find(|p| p.place.is_none()).map(|p| p.player);
|
|
580
|
+
if let Some(w) = self.players.iter_mut().find(|p| p.place.is_none()) {
|
|
581
|
+
w.place = Some(1);
|
|
582
|
+
}
|
|
583
|
+
self.events.push(with_type("won", json!({ "player": self.winner })));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
#[derive(Serialize, Deserialize, Default, Debug)]
|
|
589
|
+
pub struct ZoneHub {
|
|
590
|
+
#[serde(flatten)]
|
|
591
|
+
pub zone: Zone,
|
|
592
|
+
#[serde(default)]
|
|
593
|
+
pub kit: Publisher,
|
|
594
|
+
#[serde(default)]
|
|
595
|
+
pub receipts: Receipts,
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
impl ZoneHub {
|
|
599
|
+
fn act(&mut self, ctx: &Ctx, call: &Call<'_>, now: u64) -> Result<Vec<u8>> {
|
|
600
|
+
let out = match call.method {
|
|
601
|
+
"join" => {
|
|
602
|
+
self.zone.join(call.player()?)?;
|
|
603
|
+
encode(&true)
|
|
604
|
+
}
|
|
605
|
+
"start" => {
|
|
606
|
+
let allowed = match call.caller {
|
|
607
|
+
Caller::Developer(_) => true,
|
|
608
|
+
Caller::Player(p) => self.zone.players.first().map(|c| c.player) == Some(p),
|
|
609
|
+
_ => false,
|
|
610
|
+
};
|
|
611
|
+
if !allowed {
|
|
612
|
+
return Err(kit::error(codes::FORBIDDEN, "the first to join, or a developer, starts it"));
|
|
613
|
+
}
|
|
614
|
+
self.zone.start(now)?;
|
|
615
|
+
ctx.timer_every(SCAN, self.zone.config.scan_ms.max(250))?;
|
|
616
|
+
encode(&true)
|
|
617
|
+
}
|
|
618
|
+
other => return Err(Error::unknown_method(other)),
|
|
619
|
+
};
|
|
620
|
+
self.flush(ctx, now)?;
|
|
621
|
+
out
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
fn flush(&mut self, ctx: &Ctx, now: u64) -> Result<()> {
|
|
625
|
+
for e in std::mem::take(&mut self.zone.events) {
|
|
626
|
+
self.kit.event(ctx, ZONE_TOPIC, &e)?;
|
|
627
|
+
self.realtime(ctx, &e, now);
|
|
628
|
+
}
|
|
629
|
+
self.kit.snapshot(ctx, ZONE_TOPIC, &self.zone.view(now))
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
fn realtime(&self, ctx: &Ctx, e: &Value, now: u64) {
|
|
633
|
+
let kind = match e["type"].as_str() {
|
|
634
|
+
Some("warning" | "settled") => "hold",
|
|
635
|
+
Some("shrinking") => "shrink",
|
|
636
|
+
Some("final") => "closed",
|
|
637
|
+
_ => return,
|
|
638
|
+
};
|
|
639
|
+
let c = &self.zone.config;
|
|
640
|
+
let body = wire::ZoneChange {
|
|
641
|
+
kind: kind.into(),
|
|
642
|
+
phase: e["phase"].as_u64().map(|p| p as u32),
|
|
643
|
+
radius_now: f64::from(self.zone.circle.radius(now)),
|
|
644
|
+
center_x: c.center.0,
|
|
645
|
+
center_z: c.center.1,
|
|
646
|
+
};
|
|
647
|
+
let at = ChunkPos::new(c.center.0.floor() as i64, c.y, c.center.1.floor() as i64);
|
|
648
|
+
let actor = actor_id(&format!("zone:{}", ctx.key));
|
|
649
|
+
if let Err(Error(why)) = wire::emit_event(ctx, at, 8, &actor, wire::ZONE_CHANGE, &body) {
|
|
650
|
+
ctx.log(Level::Warn, &format!("the circle's realtime event did not go out: {why}"));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
fn on_scan(&mut self, ctx: &Ctx) -> Result<()> {
|
|
655
|
+
let now = ctx.now_ms();
|
|
656
|
+
let mut seen: Vec<Seen> = Vec::new();
|
|
657
|
+
for at in self.zone.probes() {
|
|
658
|
+
match presence::nearby(ctx, at, PROBE_RADIUS) {
|
|
659
|
+
Ok(found) => {
|
|
660
|
+
for s in found {
|
|
661
|
+
if !seen.iter().any(|x| x.player == s.player) {
|
|
662
|
+
seen.push(s);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
Err(e) => {
|
|
667
|
+
ctx.log(Level::Warn, &format!("the circle was not scanned: {}", e.message));
|
|
668
|
+
return Ok(());
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
self.zone.scan(&seen, now, self.zone.config.scan_ms);
|
|
673
|
+
if self.zone.phase == MatchPhase::Finished {
|
|
674
|
+
ctx.cancel_timer(SCAN);
|
|
675
|
+
ctx.persist_now();
|
|
676
|
+
}
|
|
677
|
+
self.flush(ctx, now)
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ---- the node ----
|
|
682
|
+
|
|
683
|
+
/// The crate's hub: one of the two types.
|
|
684
|
+
#[derive(Serialize, Deserialize, Debug)]
|
|
685
|
+
pub enum TerritoryNode {
|
|
686
|
+
Territory(TerritoryHub),
|
|
687
|
+
Zone(ZoneHub),
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
impl Hub for TerritoryNode {
|
|
691
|
+
fn spawn(ctx: &Ctx, seed: &[u8]) -> Result<Self> {
|
|
692
|
+
Ok(match mode_of(&ctx.node_type, seed)? {
|
|
693
|
+
Mode::Territory => Self::Territory(TerritoryHub { war: Territory::new(config(seed)?), ..TerritoryHub::default() }),
|
|
694
|
+
Mode::Zone => Self::Zone(ZoneHub { zone: Zone { config: config(seed)?, ..Zone::default() }, ..ZoneHub::default() }),
|
|
695
|
+
})
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
fn load(_ctx: &Ctx, snapshot: &[u8], _from_version: u64) -> Result<Self> {
|
|
699
|
+
decode(snapshot)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
fn persist(&mut self, _ctx: &Ctx) -> Result<Vec<u8>> {
|
|
703
|
+
encode(self)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
|
|
707
|
+
let now = ctx.now_ms();
|
|
708
|
+
match self {
|
|
709
|
+
Self::Territory(h) => match call.method {
|
|
710
|
+
"state" => encode(&h.war.view()),
|
|
711
|
+
kit::SNAPSHOT_METHOD => h.kit.snapshot_reply(ctx, TERRITORY_TOPIC, &h.war.view()),
|
|
712
|
+
_ => Receipts::run(h, |h| &mut h.receipts, &call, now, |h| h.act(ctx, &call)),
|
|
713
|
+
},
|
|
714
|
+
Self::Zone(h) => match call.method {
|
|
715
|
+
"state" => encode(&h.zone.view(now)),
|
|
716
|
+
kit::SNAPSHOT_METHOD => h.kit.snapshot_reply(ctx, ZONE_TOPIC, &h.zone.view(now)),
|
|
717
|
+
_ => Receipts::run(h, |h| &mut h.receipts, &call, now, |h| h.act(ctx, &call, now)),
|
|
718
|
+
},
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
|
|
723
|
+
match (self, timer) {
|
|
724
|
+
(Self::Territory(h), SCAN) => h.on_scan(ctx),
|
|
725
|
+
(Self::Territory(h), INCOME) => {
|
|
726
|
+
h.war.income();
|
|
727
|
+
h.flush(ctx)
|
|
728
|
+
}
|
|
729
|
+
(Self::Zone(h), SCAN) => h.on_scan(ctx),
|
|
730
|
+
_ => Ok(()),
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
ckx_sdk::export_hub!(TerritoryNode);
|
|
736
|
+
|
|
737
|
+
#[cfg(test)]
|
|
738
|
+
mod tests {
|
|
739
|
+
use super::*;
|
|
740
|
+
use ckx_sdk::kit::{Message, MessageKind, error_code};
|
|
741
|
+
use ckx_sdk::testing::TestHub;
|
|
742
|
+
use std::cell::RefCell;
|
|
743
|
+
use std::rc::Rc;
|
|
744
|
+
|
|
745
|
+
fn seen(player: u64, x: f64, z: f64) -> Seen {
|
|
746
|
+
Seen { player, uuid: format!("u{player}"), x, y: 0.5, z, precise: true }
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/// Answers `world.actors_radius` with the players in `world` within the probe's square.
|
|
750
|
+
fn world_of(hub: &TestHub<TerritoryNode>) -> Rc<RefCell<Vec<(u64, i64, i64)>>> {
|
|
751
|
+
let world: Rc<RefCell<Vec<(u64, i64, i64)>>> = Rc::default();
|
|
752
|
+
let w = world.clone();
|
|
753
|
+
hub.answer_node("world.actors_radius", move |body| {
|
|
754
|
+
let n = |k: &str| body[k].as_str().and_then(|v| v.parse::<i64>().ok()).unwrap_or(0);
|
|
755
|
+
let r = body["radiusXz"].as_i64().unwrap_or(0);
|
|
756
|
+
let actors: Vec<Value> = w
|
|
757
|
+
.borrow()
|
|
758
|
+
.iter()
|
|
759
|
+
.filter(|(_, x, z)| (x - n("x")).abs() <= r && (z - n("z")).abs() <= r)
|
|
760
|
+
.map(|(p, x, z)| json!({ "uuid": format!("u{p}"), "userId": p.to_string(), "chunkX": x.to_string(), "chunkY": "0", "chunkZ": z.to_string() }))
|
|
761
|
+
.collect();
|
|
762
|
+
Ok(json!({ "actors": actors }))
|
|
763
|
+
});
|
|
764
|
+
world
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
#[test]
|
|
768
|
+
fn a_type_runs_as_its_seed_says_or_as_its_name() {
|
|
769
|
+
assert_eq!(mode_of("zone", b"").unwrap(), Mode::Zone);
|
|
770
|
+
assert_eq!(mode_of("territory", b"").unwrap(), Mode::Territory);
|
|
771
|
+
assert_eq!(mode_of("br_circle", br#"{"mode":"zone","hp":50}"#).unwrap(), Mode::Zone);
|
|
772
|
+
assert!(mode_of("conquest", b"").is_err());
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
#[test]
|
|
776
|
+
fn the_faction_with_more_on_a_point_captures_it_and_a_contest_holds_it() {
|
|
777
|
+
let mut war = Territory::new(TerritoryConfig { capture_per_sec: 0.25, ..TerritoryConfig::default() });
|
|
778
|
+
war.join(1, "red").unwrap();
|
|
779
|
+
war.join(2, "red").unwrap();
|
|
780
|
+
war.join(3, "blue").unwrap();
|
|
781
|
+
let Error(e) = war.join(1, "blue").unwrap_err();
|
|
782
|
+
assert_eq!(error_code(&e).map(|c| c.0), Some(codes::CONFLICT));
|
|
783
|
+
// Two red on the mill against nobody: 0.5 a second, captured in two.
|
|
784
|
+
war.scan(&[vec![seen(1, -8.5, 0.5), seen(2, -8.2, 0.4)], vec![], vec![]], 1_000);
|
|
785
|
+
assert_eq!((war.points[0].capturing.as_deref(), war.points[0].progress), (Some("red"), 0.5));
|
|
786
|
+
war.scan(&[vec![seen(1, -8.5, 0.5), seen(2, -8.2, 0.4)], vec![], vec![]], 1_000);
|
|
787
|
+
assert_eq!(war.points[0].owner.as_deref(), Some("red"));
|
|
788
|
+
assert!(war.events.iter().any(|e| e["type"] == "captured"));
|
|
789
|
+
// One blue alone on the bridge starts it; one red joining contests it and it holds.
|
|
790
|
+
war.scan(&[vec![], vec![seen(3, 0.5, 0.5)], vec![]], 2_000);
|
|
791
|
+
let p = war.points[1].progress;
|
|
792
|
+
war.scan(&[vec![], vec![seen(3, 0.5, 0.5), seen(1, 0.4, 0.4)], vec![]], 2_000);
|
|
793
|
+
assert_eq!(war.points[1].progress, p);
|
|
794
|
+
// Nobody: it decays.
|
|
795
|
+
war.scan(&[vec![], vec![], vec![]], 2_000);
|
|
796
|
+
assert!(war.points[1].progress < p);
|
|
797
|
+
// Income for the mill, then the win.
|
|
798
|
+
war.config.win_score = Some(20);
|
|
799
|
+
war.income();
|
|
800
|
+
war.income();
|
|
801
|
+
assert_eq!((war.phase, war.winner.as_deref()), (WarPhase::Won, Some("red")));
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
#[test]
|
|
805
|
+
fn the_territory_hub_scans_where_players_stand_and_sends_a_capture_as_a_realtime_event() {
|
|
806
|
+
let mut hub = TestHub::<TerritoryNode>::spawn_with("territory", "valley", br#"{"capture_per_sec":0.5,"scan_ms":1000}"#).unwrap();
|
|
807
|
+
let world = world_of(&hub);
|
|
808
|
+
let _: bool = hub.call_typed(Caller::Player(1), "join", &json!({ "faction": "blue" })).unwrap();
|
|
809
|
+
world.borrow_mut().push((1, 8, 0));
|
|
810
|
+
hub.advance(2_500).unwrap();
|
|
811
|
+
let view: TerritoryView = hub.call_typed(Caller::Player(1), "state", &()).unwrap();
|
|
812
|
+
assert_eq!(view.points[2].owner.as_deref(), Some("blue"), "{view:?}");
|
|
813
|
+
assert_eq!(view.members["blue"], 1);
|
|
814
|
+
assert_eq!(hub.emitted().len(), 1, "the capture went out as event 96");
|
|
815
|
+
let e = hub.call(Caller::Player(1), "reset", &encode(&()).unwrap()).unwrap_err();
|
|
816
|
+
assert_eq!(error_code(&e.message).map(|c| c.0), Some(codes::FORBIDDEN));
|
|
817
|
+
let _: bool = hub.call_typed(Caller::Developer(9), "reset", &()).unwrap();
|
|
818
|
+
let snap: Message<TerritoryView> =
|
|
819
|
+
decode(&hub.call(Caller::Player(1), kit::SNAPSHOT_METHOD, &encode(&json!({ "topic": TERRITORY_TOPIC })).unwrap()).unwrap())
|
|
820
|
+
.unwrap();
|
|
821
|
+
assert_eq!(snap.body.points[2].owner, None);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
#[test]
|
|
825
|
+
fn the_circle_wears_down_the_players_outside_it_until_one_is_left() {
|
|
826
|
+
let config = ZoneConfig {
|
|
827
|
+
radius: 6.0,
|
|
828
|
+
phases: vec![ShrinkPhase { radius: 2.0, hold_ms: 2_000, shrink_ms: 2_000 }],
|
|
829
|
+
hp: 10,
|
|
830
|
+
damage_per_sec: 5,
|
|
831
|
+
..ZoneConfig::default()
|
|
832
|
+
};
|
|
833
|
+
let mut zone = Zone { config, ..Zone::default() };
|
|
834
|
+
zone.join(1).unwrap();
|
|
835
|
+
zone.join(2).unwrap();
|
|
836
|
+
zone.join(3).unwrap();
|
|
837
|
+
zone.start(0).unwrap();
|
|
838
|
+
let inner = seen(1, 0.5, 0.5);
|
|
839
|
+
let edge = seen(2, 4.5, 0.5);
|
|
840
|
+
// 3 is nowhere the probes see: outside.
|
|
841
|
+
zone.scan(&[inner.clone(), edge.clone()], 1_000, 1_000);
|
|
842
|
+
assert_eq!(zone.players.iter().map(|p| p.hp).collect::<Vec<_>>(), vec![10, 10, 5]);
|
|
843
|
+
zone.scan(&[inner.clone(), edge.clone()], 2_000, 1_000);
|
|
844
|
+
assert_eq!(zone.players[2].place, Some(3));
|
|
845
|
+
// The circle shrinks to 2: the edge is outside now.
|
|
846
|
+
for t in [3_000, 4_000, 5_000] {
|
|
847
|
+
zone.scan(&[inner.clone(), edge.clone()], t, 1_000);
|
|
848
|
+
}
|
|
849
|
+
assert_eq!((zone.phase, zone.winner), (MatchPhase::Finished, Some(1)));
|
|
850
|
+
assert_eq!(zone.players[1].place, Some(2));
|
|
851
|
+
let kinds: Vec<&str> = zone.events.iter().filter_map(|e| e["type"].as_str()).collect();
|
|
852
|
+
assert!(
|
|
853
|
+
kinds.contains(&"warning") && kinds.contains(&"shrinking") && kinds.contains(&"eliminated") && kinds.contains(&"won"),
|
|
854
|
+
"{kinds:?}"
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
#[test]
|
|
859
|
+
fn the_zone_hub_probes_around_its_centre_and_publishes_each_scan() {
|
|
860
|
+
let mut hub = TestHub::<TerritoryNode>::spawn("zone", "match-1").unwrap();
|
|
861
|
+
let world = world_of(&hub);
|
|
862
|
+
world.borrow_mut().extend([(1, 0, 0), (2, 9, 9)]);
|
|
863
|
+
let _: bool = hub.call_typed(Caller::Player(1), "join", &()).unwrap();
|
|
864
|
+
let _: bool = hub.call_typed(Caller::Player(2), "join", &()).unwrap();
|
|
865
|
+
let _: bool = hub.call_typed(Caller::Player(1), "start", &()).unwrap();
|
|
866
|
+
hub.advance(1_000).unwrap();
|
|
867
|
+
let probes = hub.node_requests().iter().filter(|(op, _)| op == "world.actors_radius").count();
|
|
868
|
+
assert_eq!(probes, 9);
|
|
869
|
+
let view: ZoneView = hub.call_typed(Caller::Player(1), "state", &()).unwrap();
|
|
870
|
+
assert_eq!(view.players.iter().map(|p| p.outside).collect::<Vec<_>>(), vec![false, true]);
|
|
871
|
+
#[derive(Deserialize)]
|
|
872
|
+
struct K {
|
|
873
|
+
kind: MessageKind,
|
|
874
|
+
}
|
|
875
|
+
assert!(hub.published().iter().any(|(_, b)| decode::<K>(b).unwrap().kind == MessageKind::Event), "the first warning");
|
|
876
|
+
assert!(!hub.emitted().is_empty(), "the circle's warning went out as event 98");
|
|
877
|
+
}
|
|
878
|
+
}
|