1use 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;
25pub use crate::economy::films::FilmRefusal;
28#[doc(hidden)]
29pub use bomb::{BombSearch, Guard};
30
31#[derive(Clone, Debug)]
33pub struct PendingChoice {
34 pub kind: &'static str,
37 pub cost: f64,
39 pub message: String,
41}
42
43pub struct EventSystem {
46 #[doc(hidden)]
47 pub active: IndexSet<i64>,
49 #[doc(hidden)]
50 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 pub extra: Rng,
61 #[doc(hidden)]
62 pub last_santa_year: f64,
65 pub pending: Option<PendingChoice>,
67 #[doc(hidden)]
68 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 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 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 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 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 pub fn resolve_choice_from(&mut self, accept: bool, source: EmergencySource) {
193 let Some(p) = self.events.pending.take() else {
194 return;
195 };
196 let paid = accept && self.money >= p.cost;
199 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 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 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 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 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 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 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}