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;