Skip to main content
Verticopolis engineGitHub

verticopolis_engine/
gameplay.rs

1//! Gameplay events: facts the engine reports the moment something happens,
2//! such as a room placed, a star reached or a fire started, for a host to
3//! feed achievements, analytics or sound.
4//!
5//! The engine pushes each event into [`Simulation::gameplay`](crate::sim::Simulation::gameplay),
6//! a ring of at most [`GAMEPLAY_RING_CAP`]. Drain it after each frame. If a
7//! host never drains, the oldest events go and
8//! [`GameplayEvents::dropped`] counts them. Events carry only enums and
9//! bounded integers: no names, money or free text.
10//!
11//! [`CATALOG_JSON`] is the contract: every event's name, payload fields and
12//! meaning, versioned. [`check_event`] checks a drained event against it.
13//!
14//! ```
15//! use verticopolis_engine::clock::GameMode;
16//! use verticopolis_engine::facilities::Kind;
17//! use verticopolis_engine::gameplay::GameplayEvent;
18//! use verticopolis_engine::sim::Simulation;
19//!
20//! let mut sim = Simulation::new_game(1, GameMode::Classic);
21//! sim.build(Kind::Lobby, 1, 100);
22//! let events = sim.gameplay.drain();
23//! assert_eq!(events[0], GameplayEvent::TowerFounded { mode: GameMode::Classic });
24//! assert_eq!(events[1].name(), "facility_placed");
25//! println!("{}", events[1].to_json());
26//! ```
27
28use std::collections::VecDeque;
29
30mod check;
31pub use check::{check_catalog, check_event};
32
33use serde_json::{json, Value};
34
35use crate::clock::GameMode;
36use crate::facilities::Kind;
37
38/// The most events the ring holds between two drains.
39pub const GAMEPLAY_RING_CAP: usize = 1024;
40
41/// How a player removed something, in [`GameplayEvent::FacilityRemoved`]
42/// and [`Simulation::remove_facility`](crate::sim::Simulation::remove_facility).
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum RemovalMethod {
45    /// Sold with the sell tool.
46    Sell,
47    /// Cleared with the bulldozer.
48    Bulldoze,
49}
50
51impl RemovalMethod {
52    /// `sell` or `bulldoze`.
53    pub fn parse(s: &str) -> Option<RemovalMethod> {
54        match s {
55            "sell" => Some(RemovalMethod::Sell),
56            "bulldoze" => Some(RemovalMethod::Bulldoze),
57            _ => None,
58        }
59    }
60
61    /// `sell` or `bulldoze`.
62    pub fn as_str(self) -> &'static str {
63        match self {
64            RemovalMethod::Sell => "sell",
65            RemovalMethod::Bulldoze => "bulldoze",
66        }
67    }
68}
69
70/// Which offer an [`GameplayEvent::EmergencyResolved`] answered.
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub enum EmergencyKind {
73    /// Pay to stop a fire spreading.
74    FireRescue,
75    /// Pay a bomb threat's ransom.
76    BombThreat,
77}
78
79impl EmergencyKind {
80    /// `fireRescue` or `bombThreat`.
81    pub fn as_str(self) -> &'static str {
82        match self {
83            EmergencyKind::FireRescue => "fireRescue",
84            EmergencyKind::BombThreat => "bombThreat",
85        }
86    }
87
88    /// `fireRescue` or `bombThreat`.
89    pub fn parse(s: &str) -> Option<EmergencyKind> {
90        match s {
91            "fireRescue" => Some(EmergencyKind::FireRescue),
92            "bombThreat" => Some(EmergencyKind::BombThreat),
93            _ => None,
94        }
95    }
96}
97
98/// What took effect when an offer was answered.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum EmergencyDecision {
101    /// Paid.
102    Accept,
103    /// Declined, or accepted without the money to pay.
104    Decline,
105}
106
107impl EmergencyDecision {
108    /// `accept` or `decline`.
109    pub fn as_str(self) -> &'static str {
110        match self {
111            EmergencyDecision::Accept => "accept",
112            EmergencyDecision::Decline => "decline",
113        }
114    }
115}
116
117/// Who answered an offer.
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub enum EmergencySource {
120    /// The player, with [`Simulation::resolve_choice`](crate::sim::Simulation::resolve_choice).
121    Player,
122    /// Nobody answered by the next day, so the engine declined.
123    Timeout,
124}
125
126impl EmergencySource {
127    /// `player` or `timeout`.
128    pub fn as_str(self) -> &'static str {
129        match self {
130            EmergencySource::Player => "player",
131            EmergencySource::Timeout => "timeout",
132        }
133    }
134}
135
136/// One gameplay event. [`CATALOG_JSON`] holds the full meaning of each, under
137/// its [`name`](GameplayEvent::name).
138#[derive(Clone, Debug, PartialEq)]
139pub enum GameplayEvent {
140    /// `tower_founded`: a new game started. A load never emits it.
141    TowerFounded {
142        /// The game's mode.
143        mode: GameMode,
144    },
145    /// `facility_placed`: a build was placed and charged.
146    FacilityPlaced {
147        /// The kind placed.
148        kind: Kind,
149        /// The top floor it reaches.
150        floor: i64,
151        /// How many of `kind` it placed (more than one for a floor run).
152        count: i64,
153    },
154    /// `facility_removed`: a player sold or bulldozed a room or a shaft.
155    FacilityRemoved {
156        /// The kind removed.
157        kind: Kind,
158        /// How it was removed.
159        method: RemovalMethod,
160    },
161    /// `pricing_changed`: a price moved, once per command.
162    PricingChanged {
163        /// The kind repriced.
164        kind: Kind,
165    },
166    /// `capacity_changed`: an elevator's cars or span changed.
167    CapacityChanged {
168        /// The shaft's kind.
169        kind: Kind,
170    },
171    /// `star_reached`: the rating rose to `star` (6 is TOWER), one event per
172    /// star gained.
173    StarReached {
174        /// The new rating.
175        star: i64,
176    },
177    /// `fire_started`: a room caught fire. A fire spreading is the same fire.
178    FireStarted,
179    /// `fire_gutted`: a fire burned rooms down to empty shells.
180    FireGutted {
181        /// How many rooms, at least 1.
182        rooms: i64,
183    },
184    /// `bomb_detonated`: a bomb went off before Security found it.
185    BombDetonated {
186        /// How many rooms the blast destroyed.
187        rooms: i64,
188    },
189    /// `bomb_found`: Security found the bomb in time.
190    BombFound,
191    /// `emergency_resolved`: an offer was answered.
192    EmergencyResolved {
193        /// Which offer.
194        kind: EmergencyKind,
195        /// What took effect.
196        decision: EmergencyDecision,
197        /// Who answered.
198        source: EmergencySource,
199    },
200    /// `milestone_reached`: the tower reached a milestone. A load never
201    /// emits it.
202    MilestoneReached {
203        /// The milestone's id, stable across versions.
204        id: &'static str,
205    },
206}
207
208impl GameplayEvent {
209    #[doc(hidden)]
210    /// How many variants there are: one more than the highest `ordinal`.
211    pub const VARIANTS: usize = 12;
212
213    #[doc(hidden)]
214    /// The event's position in the catalog. Exhaustive, so a new variant
215    /// cannot compile without a place; `VARIANTS` and the catalog test then
216    /// hold the count, `one_of_each` and the catalog to it.
217    pub fn ordinal(&self) -> usize {
218        match self {
219            GameplayEvent::TowerFounded { .. } => 0,
220            GameplayEvent::FacilityPlaced { .. } => 1,
221            GameplayEvent::FacilityRemoved { .. } => 2,
222            GameplayEvent::PricingChanged { .. } => 3,
223            GameplayEvent::CapacityChanged { .. } => 4,
224            GameplayEvent::StarReached { .. } => 5,
225            GameplayEvent::FireStarted => 6,
226            GameplayEvent::FireGutted { .. } => 7,
227            GameplayEvent::BombDetonated { .. } => 8,
228            GameplayEvent::EmergencyResolved { .. } => 9,
229            GameplayEvent::MilestoneReached { .. } => 10,
230            GameplayEvent::BombFound => 11,
231        }
232    }
233
234    /// The event's name in the catalog, such as `facility_placed`.
235    pub fn name(&self) -> &'static str {
236        match self {
237            GameplayEvent::TowerFounded { .. } => "tower_founded",
238            GameplayEvent::FacilityPlaced { .. } => "facility_placed",
239            GameplayEvent::FacilityRemoved { .. } => "facility_removed",
240            GameplayEvent::PricingChanged { .. } => "pricing_changed",
241            GameplayEvent::CapacityChanged { .. } => "capacity_changed",
242            GameplayEvent::StarReached { .. } => "star_reached",
243            GameplayEvent::FireStarted => "fire_started",
244            GameplayEvent::FireGutted { .. } => "fire_gutted",
245            GameplayEvent::BombDetonated { .. } => "bomb_detonated",
246            GameplayEvent::EmergencyResolved { .. } => "emergency_resolved",
247            GameplayEvent::MilestoneReached { .. } => "milestone_reached",
248            GameplayEvent::BombFound => "bomb_found",
249        }
250    }
251
252    /// The event's payload fields as a JSON object.
253    pub fn payload(&self) -> Value {
254        match self {
255            GameplayEvent::TowerFounded { mode } => json!({ "mode": mode.as_str() }),
256            GameplayEvent::FacilityPlaced { kind, floor, count } => {
257                json!({ "kind": kind.as_str(), "floor": floor, "count": count })
258            }
259            GameplayEvent::FacilityRemoved { kind, method } => {
260                json!({ "kind": kind.as_str(), "method": method.as_str() })
261            }
262            GameplayEvent::PricingChanged { kind } | GameplayEvent::CapacityChanged { kind } => {
263                json!({ "kind": kind.as_str() })
264            }
265            GameplayEvent::StarReached { star } => json!({ "star": star }),
266            GameplayEvent::FireStarted | GameplayEvent::BombFound => json!({}),
267            GameplayEvent::FireGutted { rooms } | GameplayEvent::BombDetonated { rooms } => {
268                json!({ "rooms": rooms })
269            }
270            GameplayEvent::EmergencyResolved {
271                kind,
272                decision,
273                source,
274            } => {
275                json!({ "kind": kind.as_str(), "decision": decision.as_str(), "source": source.as_str() })
276            }
277            GameplayEvent::MilestoneReached { id } => json!({ "id": id }),
278        }
279    }
280
281    /// The event as `{ "name": ..., "payload": {...} }`, the shape
282    /// [`CATALOG_JSON`] describes.
283    pub fn to_json(&self) -> Value {
284        json!({ "name": self.name(), "payload": self.payload() })
285    }
286}
287
288/// The events waiting for the host to drain them. Never saved.
289#[derive(Clone, Debug, Default)]
290pub struct GameplayEvents {
291    ring: VecDeque<GameplayEvent>,
292    /// Events pushed out of a full ring since the engine was made.
293    pub dropped: u64,
294}
295
296impl GameplayEvents {
297    #[doc(hidden)]
298    pub fn push(&mut self, e: GameplayEvent) {
299        if self.ring.len() >= GAMEPLAY_RING_CAP {
300            self.ring.pop_front();
301            self.dropped += 1;
302        }
303        self.ring.push_back(e);
304    }
305
306    /// Every buffered event, oldest first; the ring is empty afterwards.
307    pub fn drain(&mut self) -> Vec<GameplayEvent> {
308        self.ring.drain(..).collect()
309    }
310
311    /// How many events are waiting.
312    pub fn len(&self) -> usize {
313        self.ring.len()
314    }
315
316    /// The buffered events, oldest first, left in place.
317    pub fn iter(&self) -> impl Iterator<Item = &GameplayEvent> {
318        self.ring.iter()
319    }
320
321    /// Whether no events are waiting.
322    pub fn is_empty(&self) -> bool {
323        self.ring.is_empty()
324    }
325}
326
327#[doc(hidden)]
328/// The drained batch as the JSON array the binding returns and the
329/// conformance lock hashes.
330pub fn batch_json(events: &[GameplayEvent]) -> Value {
331    Value::Array(events.iter().map(GameplayEvent::to_json).collect())
332}
333
334/// The gameplay event catalog as JSON: every event's name, cardinality,
335/// payload fields and meaning. The conformance suite holds the engine to it.
336// A static, so the reference shows the name and type without printing the
337// whole file as the value.
338pub static CATALOG_JSON: &str = include_str!("../../conformance/events/catalog.json");
339
340#[cfg(test)]
341mod tests;