Skip to main content
Verticopolis engineGitHub

verticopolis_engine/
events.rs

1//! Emergencies and the choices they put to the player.
2//!
3//! When a fire breaks out or a bomb threat arrives, the engine sets
4//! [`EventSystem::pending`] on [`Simulation::events`] and writes the offer to
5//! the log; the frame's header flags it too. The game keeps running. A host
6//! shows the offer and sends the player's answer with
7//! [`Simulation::resolve_choice`].
8
9use indexmap::IndexSet;
10use serde_json::{json, Value};
11
12use crate::facilities::Kind;
13use crate::gameplay::{EmergencyDecision, EmergencyKind, EmergencySource, GameplayEvent};
14use crate::rng::Rng;
15use crate::services::with_thousands;
16use crate::sim::{LogKind, Simulation, ThiefFx};
17use crate::tower::UnitState;
18
19#[path = "events_fire.rs"]
20mod fire;
21
22#[path = "events_bomb.rs"]
23#[doc(hidden)]
24pub mod bomb;
25/// Why [`Simulation::change_film`] refused, kept here beside the other
26/// cinema command, [`Simulation::set_film_policy`].
27pub use crate::economy::films::FilmRefusal;
28#[doc(hidden)]
29pub use bomb::{BombSearch, Guard};
30
31/// An offer waiting for the player's answer.
32#[derive(Clone, Debug)]
33pub struct PendingChoice {
34    /// `fireRescue` (pay to stop a fire spreading) or `bombThreat` (pay the
35    /// ransom, or let the search for the bomb begin).
36    pub kind: &'static str,
37    /// What accepting costs, in dollars.
38    pub cost: f64,
39    /// The offer, ready to show a player.
40    pub message: String,
41}
42
43/// The tower's emergencies. A host reads [`pending`](EventSystem::pending);
44/// the rest is the engine's own state.
45pub struct EventSystem {
46    #[doc(hidden)]
47    /// Units currently ablaze, in insertion order.
48    pub active: IndexSet<i64>,
49    #[doc(hidden)]
50    /// `counts`: emergency tallies for this tower instance (read by the
51    /// shell; never saved). A spreading blaze is the same fire, so only the
52    /// ignition counts; bomb losses are not fire losses.
53    pub fires_started: i64,
54    #[doc(hidden)]
55    pub fires_gut_rooms: i64,
56    #[doc(hidden)]
57    pub bombs_detonated: i64,
58    #[doc(hidden)]
59    /// The seasonal and visitor stream, separate from the main one.
60    pub extra: Rng,
61    #[doc(hidden)]
62    /// The year Santa last came, as the save carries it (a forged save may
63    /// hold a fraction, which then never equals a year).
64    pub last_santa_year: f64,
65    /// The offer waiting for the player's answer, if any.
66    pub pending: Option<PendingChoice>,
67    #[doc(hidden)]
68    /// Classic's bomb search while one runs (`events_bomb.rs`).
69    pub bomb: Option<BombSearch>,
70}
71
72const SECURITY_RADIUS: i64 = 8;
73const MEDICAL_RADIUS: i64 = 12;
74const THIEF_DAILY_CHANCE: f64 = 0.025;
75
76impl EventSystem {
77    #[doc(hidden)]
78    pub fn new(seed: u32) -> EventSystem {
79        EventSystem {
80            active: IndexSet::new(),
81            fires_started: 0,
82            fires_gut_rooms: 0,
83            bombs_detonated: 0,
84            extra: Rng::new(seed ^ 0x5a17a),
85            last_santa_year: -1.0,
86            pending: None,
87            bomb: None,
88        }
89    }
90
91    #[doc(hidden)]
92    /// `saveState()`: `pending` is a literal null when there is no choice.
93    /// `sim_save.rs` writes this object itself too (#1242), with the pending
94    /// choice's message from the borrow; its tests hold the two to the same
95    /// text.
96    pub fn save_state(&self) -> Value {
97        let pending = match &self.pending {
98            Some(p) => json!({ "kind": p.kind, "cost": p.cost, "message": p.message }),
99            None => Value::Null,
100        };
101        let mut state = json!({ "lastSantaYear": self.last_santa_year, "rngState": self.extra.seed(), "pending": pending });
102        if let Some(b) = &self.bomb {
103            state["bomb"] = b.save_state();
104        }
105        state
106    }
107
108    #[doc(hidden)]
109    pub fn count(&self) -> usize {
110        self.active.len()
111    }
112}
113
114impl Simulation {
115    /// Sets the film cinema `id` books again once its film is spent (Modern):
116    /// `auto` or `feature` (a feature), `revival`, `blockbuster`, or `off`.
117    /// Returns the policy set, or `None` when `id` is not a cinema or
118    /// `policy` is none of the five.
119    pub fn set_film_policy(&mut self, id: i64, policy: &str) -> Option<&'static str> {
120        let policy: &'static str = match policy {
121            "auto" => "auto",
122            "feature" => "feature",
123            "blockbuster" => "blockbuster",
124            "revival" => "revival",
125            "off" => "off",
126            _ => return None,
127        };
128        let u = self.tower.get_unit_mut(id)?;
129        if u.kind != Kind::Cinema {
130            return None;
131        }
132        u.extras_mut().film_policy = Some(policy);
133        Some(policy)
134    }
135
136    #[doc(hidden)]
137    /// `maybeRandomEvent`: the daily roll.
138    pub fn maybe_random_event(&mut self) {
139        if self.events.pending.is_some() {
140            self.resolve_choice_from(false, EmergencySource::Timeout);
141        }
142        self.process_fires();
143        self.maybe_santa();
144        self.maybe_thief();
145        if self.star < 2 {
146            return;
147        }
148        let roll = self.rng.next();
149        let fire_chance = self.fire_chance();
150        if self.events.active.is_empty() && roll < fire_chance {
151            self.start_fire();
152            if !self.events.active.is_empty() {
153                self.offer_fire_rescue();
154            }
155            return;
156        }
157        let ransom_allowed = self.events.bomb.is_none()
158            && (!self.mode.ransom_needs_security() || self.has_any(Kind::Security));
159        if self.star >= 4 && roll < fire_chance + 0.05 && ransom_allowed {
160            let message = "๐Ÿ’ฃ A caller demands a $300,000 ransom or a bomb detonates. Pay, or have Security search the tower.".to_string();
161            self.events.pending = Some(PendingChoice {
162                kind: "bombThreat",
163                cost: 300_000.0,
164                message: message.clone(),
165            });
166            self.emit(&message, LogKind::Bad);
167            return;
168        }
169        if self.rng.chance(0.15) {
170            if self.rng.chance(0.5) {
171                self.emit(
172                    "A local newspaper praised your tower's design.",
173                    LogKind::Good,
174                );
175            } else {
176                self.emit("Tenants are happy with the tower today.", LogKind::Info);
177            }
178        }
179    }
180
181    /// Answers the [pending choice](EventSystem::pending): `true` pays, `false`
182    /// declines. Accepting without the money to pay counts as declining. Does
183    /// nothing when no choice is pending. A declined fire spreads until it
184    /// burns out; a declined bomb threat starts the search for the bomb.
185    pub fn resolve_choice(&mut self, accept: bool) {
186        self.outside_the_gesture(|s| s.resolve_choice_from(accept, EmergencySource::Player));
187    }
188
189    #[doc(hidden)]
190    /// `EventSystem.resolveChoice(option, source)`: the daily roll's
191    /// auto-decline comes through here as a timeout.
192    pub fn resolve_choice_from(&mut self, accept: bool, source: EmergencySource) {
193        let Some(p) = self.events.pending.take() else {
194            return;
195        };
196        // What takes effect: an accept the tower cannot afford is a decline.
197        // One test drives both the event and the branches below.
198        let paid = accept && self.money >= p.cost;
199        // Anything but a fire rescue is answered as a bomb threat, as the
200        // branches below (and the TypeScript) treat it.
201        let kind = EmergencyKind::parse(p.kind).unwrap_or(EmergencyKind::BombThreat);
202        self.gameplay.push(GameplayEvent::EmergencyResolved {
203            kind,
204            decision: if paid {
205                EmergencyDecision::Accept
206            } else {
207                EmergencyDecision::Decline
208            },
209            source,
210        });
211        if kind == EmergencyKind::FireRescue {
212            if paid {
213                self.money -= p.cost;
214                self.extinguish_all();
215                self.emit(&format!("๐Ÿš’ Fire-rescue crews saved the tower for ${}. The rooms that were ablaze are gutted. Bulldoze and rebuild them.", with_thousands(p.cost)), LogKind::Money);
216            }
217            return;
218        }
219        if paid {
220            self.money -= p.cost;
221            self.emit(
222                &format!(
223                    "๐Ÿ’ฃ You paid the ${} ransom; the threat passed quietly.",
224                    with_thousands(p.cost)
225                ),
226                LogKind::Money,
227            );
228        } else {
229            self.start_bomb_search();
230        }
231    }
232
233    fn gut(&mut self, i: usize) {
234        let (u, at) = self.tower.marked(i);
235        u.set_state(at, UnitState::Gutted);
236        u.set_occupants(at, 0);
237        u.ever_occupied = false;
238        u.set_residents(at, None);
239        // A gutted venue reopens as a new one and counts no visitors (#1500).
240        u.set_visitors_yest(at, None);
241        u.satisfaction = 0.0;
242        u.pending_income = 0.0;
243        {
244            let mut extras = u.extras_mut();
245            extras.patronage_today = None;
246            extras.patronage_yest = None;
247            extras.profit_today = None;
248            extras.profit_yest = None;
249            extras.dirty_days = None;
250            // A rebuilt theatre opens with a new film.
251            extras.film_days = None;
252            extras.film_classic = false;
253            extras.film_blockbuster = false;
254        }
255        let name = u.kind.facility().name;
256        self.tower.set_unit_label_at(i, name.to_string());
257    }
258
259    fn extinguish_all(&mut self) {
260        let ids: Vec<i64> = self.events.active.iter().copied().collect();
261        let mut gutted = 0;
262        for id in ids {
263            if let Some(idx) = self.tower.units.iter().position(|u| u.id == id) {
264                if self.tower.units[idx].state == UnitState::Fire {
265                    self.gut(idx);
266                    self.events.fires_gut_rooms += 1;
267                    gutted += 1;
268                }
269            }
270        }
271        self.events.active.clear();
272        self.note_gutted(gutted);
273    }
274
275    /// `fire_gutted` for one step that gutted `rooms` (none for zero).
276    fn note_gutted(&mut self, rooms: i64) {
277        if rooms > 0 {
278            self.gameplay.push(GameplayEvent::FireGutted { rooms });
279        }
280    }
281
282    #[doc(hidden)]
283    pub fn fire_chance(&self) -> f64 {
284        let mut chance = 0.025;
285        if self.has_operational(Kind::Security) {
286            chance *= 0.45;
287        }
288        if self.has_operational(Kind::Medical) {
289            chance *= 0.5;
290        }
291        chance
292    }
293
294    fn fire_rescue_cost(&self) -> f64 {
295        (150_000.0 + ((self.star - 2).max(0) as f64) * 120_000.0).min(500_000.0)
296    }
297
298    /// `flammableUnits`: indices of real, finished, unburned rooms.
299    fn flammable_units(&self) -> Vec<usize> {
300        (0..self.tower.units.len())
301            .filter(|&i| {
302                let u = &self.tower.units[i];
303                !u.kind.is_structural()
304                    && u.state != UnitState::Construction
305                    && u.state != UnitState::Fire
306                    && u.state != UnitState::Gutted
307            })
308            .collect()
309    }
310
311    #[doc(hidden)]
312    /// `startFire`.
313    pub fn start_fire(&mut self) {
314        let candidates = self.flammable_units();
315        if candidates.is_empty() {
316            return;
317        }
318        let i = *self.rng.pick(&candidates);
319        self.ignite(i);
320    }
321
322    fn service_within(&self, kind: Kind, floor: i64, radius: i64) -> bool {
323        self.tower
324            .units
325            .iter()
326            .any(|u| u.kind == kind && u.is_operational() && (u.floor - floor).abs() <= radius)
327    }
328
329    #[doc(hidden)]
330    pub fn control_chance(&self, floor: i64) -> f64 {
331        let sec = self.service_within(Kind::Security, floor, SECURITY_RADIUS);
332        let med = self.service_within(Kind::Medical, floor, MEDICAL_RADIUS);
333        0.5 + (if sec { 0.2 } else { 0.0 }) + (if med { 0.3 } else { 0.0 })
334    }
335
336    fn spread_fire_to(&mut self, target: Option<i64>) {
337        let Some(tid) = target else { return };
338        let Some(idx) = self.tower.units.iter().position(|u| u.id == tid) else {
339            return;
340        };
341        if !self.tower.units[idx].is_operational() {
342            return;
343        }
344        let kind = self.tower.units[idx].kind;
345        let mut ops = 0;
346        for x in &self.tower.units {
347            if x.kind == kind && x.is_operational() {
348                ops += 1;
349                if ops > 1 {
350                    break;
351                }
352            }
353        }
354        if ops <= 1 {
355            return;
356        }
357        let (u, at) = self.tower.marked(idx);
358        u.set_state(at, UnitState::Fire);
359        u.set_occupants(at, 0);
360        let (id, name, floor) = (u.id, kind.facility().name, u.floor);
361        self.events.active.insert(id);
362        let msg = format!(
363            "The fire spread to {} on {}!",
364            name,
365            self.floor_label(floor)
366        );
367        self.emit(&msg, LogKind::Bad);
368    }
369
370    fn process_fires(&mut self) {
371        if self.events.active.is_empty() {
372            return;
373        }
374        let ids: Vec<i64> = self.events.active.iter().copied().collect();
375        let mut gutted = 0;
376        for id in ids {
377            let Some(idx) = self.tower.units.iter().position(|u| u.id == id) else {
378                self.events.active.shift_remove(&id);
379                continue;
380            };
381            if self.tower.units[idx].state != UnitState::Fire {
382                self.events.active.shift_remove(&id);
383                continue;
384            }
385            let (floor, x, width, name) = {
386                let u = &self.tower.units[idx];
387                (u.floor, u.x, u.width, u.kind.facility().name)
388            };
389            let control = self.control_chance(floor);
390            if self.rng.chance(control) {
391                self.gut(idx);
392                self.events.fires_gut_rooms += 1;
393                gutted += 1;
394                self.events.active.shift_remove(&id);
395                let msg = format!("๐Ÿ”ฅ The {} on {} burned down. Only a gutted shell remains. Bulldoze the rubble and rebuild.", name, self.floor_label(floor));
396                self.emit(&msg, LogKind::Bad);
397            } else {
398                let adjacent = self
399                    .tower
400                    .room_at(floor, x - 1)
401                    .or_else(|| self.tower.room_at(floor, x + width))
402                    .map(|u| u.id);
403                self.spread_fire_to(adjacent);
404                let above = self.tower.room_at(floor + 1, x).map(|u| u.id);
405                self.spread_fire_to(above);
406            }
407        }
408        self.note_gutted(gutted);
409        if !self.events.active.is_empty() {
410            for u in self.tower.units.iter_mut() {
411                if u.is_tenanted() || u.state == UnitState::Asleep {
412                    u.satisfaction = (u.satisfaction - 0.05).max(0.0);
413                }
414            }
415        }
416    }
417
418    fn maybe_santa(&mut self) {
419        let cal = self.clock.calendar;
420        let year = self.clock.year();
421        let day_of_year = ((self.clock.day() % cal.year_days) + cal.year_days) % cal.year_days;
422        let holiday_start = cal.year_days
423            - (crate::jsmath::round((cal.year_days as f64 * 20.0) / 360.0) as i64).max(1);
424        if day_of_year < holiday_start
425            || self.star < 3
426            || year as f64 == self.events.last_santa_year
427        {
428            return;
429        }
430        if !self.events.extra.chance(0.4) {
431            return;
432        }
433        self.events.last_santa_year = year as f64;
434        self.emit(
435            "๐ŸŽ… Santa was spotted crossing the sky above your tower for the holidays!",
436            LogKind::Good,
437        );
438        self.fx.santa_seq += 1;
439    }
440
441    fn maybe_thief(&mut self) {
442        if self.star < 2 {
443            return;
444        }
445        if !self.events.extra.chance(THIEF_DAILY_CHANCE) {
446            return;
447        }
448        // The floor places the cosmetic (`triggerThief`); the draw also keeps
449        // the event stream in step with the TypeScript.
450        let floor = self.thief_floor();
451        if self.has_any(Kind::Security) {
452            self.emit(
453                "๐Ÿ•ต๏ธ Security caught a thief prowling the tower. Nothing was taken.",
454                LogKind::Good,
455            );
456            self.fx.thief = ThiefFx {
457                caught: true,
458                floor,
459                seq: self.fx.thief.seq + 1,
460            };
461            return;
462        }
463        self.fx.thief = ThiefFx {
464            caught: false,
465            floor,
466            seq: self.fx.thief.seq + 1,
467        };
468        let loss = 5_000.0 + self.events.extra.int(0, 20_000) as f64;
469        self.money -= loss;
470        self.emit(
471            &format!(
472                "๐Ÿ•ต๏ธ A thief slipped through the tower and made off with ${}. Build Security.",
473                with_thousands(loss)
474            ),
475            LogKind::Bad,
476        );
477    }
478
479    fn thief_floor(&mut self) -> i64 {
480        let mut floors: IndexSet<i64> = IndexSet::new();
481        for u in &self.tower.units {
482            if u.is_present() {
483                floors.insert(u.floor);
484            }
485        }
486        if floors.is_empty() {
487            return 1;
488        }
489        let v: Vec<i64> = floors.into_iter().collect();
490        *self.events.extra.pick(&v)
491    }
492}