Skip to main content
Verticopolis engineGitHub

verticopolis_engine/
sim_loop.rs

1// The time step (from the TypeScript reference's `sim/loop.ts`) and the
2// weather hash.
3
4use crate::crowd::{motion, spawn, CROWD_SECONDS_PER_MINUTE};
5use crate::economy::films::fills_blockbuster_slot;
6use crate::facilities::Kind;
7use crate::sim::{LogKind, Simulation};
8use crate::tower::UnitState;
9
10pub const CROWD_MAX_STEP: f64 = 60.0;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Weather {
14    Clear,
15    Cloudy,
16    Rain,
17}
18
19/// `weatherFor(day)`.
20pub fn weather_for(day: i64) -> Weather {
21    let mut h = (day as i32 as u32).wrapping_mul(2654435761);
22    h ^= h >> 13;
23    h = h.wrapping_mul(1274126177);
24    let r = ((h >> 8) & 0xffff) as f64 / 65536.0;
25    if r < 0.62 {
26        Weather::Clear
27    } else if r < 0.85 {
28        Weather::Cloudy
29    } else {
30        Weather::Rain
31    }
32}
33
34impl Simulation {
35    /// Advances the game by `dt_minutes` game minutes.
36    ///
37    /// Any `dt` works: the engine splits it into steps of at most 30 minutes
38    /// that never cross an hour, so hourly rules always run. The same seed,
39    /// the same commands and the same sequence of `dt` give the same game.
40    /// A host that runs on display frames should let
41    /// [`FrameStepper`](crate::fixed_step::FrameStepper) choose the `dt`, so
42    /// the tower comes out the same at any frame rate.
43    ///
44    /// ```
45    /// use verticopolis_engine::clock::GameMode;
46    /// use verticopolis_engine::sim::Simulation;
47    ///
48    /// let mut sim = Simulation::new_game(1, GameMode::Classic);
49    /// let day = sim.clock.day();
50    /// sim.tick(24.0 * 60.0);
51    /// assert_eq!(sim.clock.day(), day + 1);
52    /// ```
53    pub fn tick(&mut self, dt_minutes: f64) {
54        const EPS: f64 = 1e-6;
55        let mut remaining = dt_minutes;
56        while remaining > EPS {
57            let to_next_hour = 60.0 - (self.clock.minute_of_day() % 60.0);
58            let cap = if to_next_hour > EPS {
59                to_next_hour.min(30.0)
60            } else {
61                30.0
62            };
63            let step = remaining.min(cap);
64            self.advance_step(step);
65            remaining -= step;
66        }
67    }
68
69    #[doc(hidden)]
70    /// One simulation step. An open undo gesture is paused around it, so
71    /// what time does to the tower is never part of an undo step.
72    pub fn advance_step(&mut self, dt_minutes: f64) {
73        let paused = self.pause_undo_journal();
74        self.advance_step_inner(dt_minutes);
75        self.resume_undo_journal(paused);
76    }
77
78    fn advance_step_inner(&mut self, dt_minutes: f64) {
79        self.clock.advance(dt_minutes);
80        let rush = self.rush_factor();
81        let population = self.elevators.accumulate(&self.tower, dt_minutes, rush);
82        self.crowd.begin_step();
83        // The renderer's queue projection, built once a step at its start
84        // (`Crowd::queue_view`), as the TypeScript loop primes it.
85        self.crowd.queue_view(&self.tower);
86        // A fractional id (a forged save) matches no cinema, so only whole
87        // ids reach the crowd's set.
88        self.crowd.blockbusters = self
89            .blockbusters
90            .iter()
91            .filter(|b| b.fract() == 0.0)
92            .map(|b| *b as i64)
93            .collect();
94        spawn::spawn_step(
95            &mut self.crowd,
96            (dt_minutes * CROWD_SECONDS_PER_MINUTE).min(CROWD_MAX_STEP),
97            &mut self.tower,
98            &self.clock,
99            Some(self.weather),
100            population,
101        );
102        let move_minutes = dt_minutes.min(CROWD_MAX_STEP / CROWD_SECONDS_PER_MINUTE);
103        let mut left = move_minutes;
104        while left > 0.0 {
105            let chunk = left.min(2.5);
106            let calls = self.crowd.elevator_calls(&self.tower);
107            let (hour, weekend) = (self.clock.hour(), self.clock.is_weekend());
108            self.elevators
109                .move_cars(&mut self.tower, chunk, &calls, hour, weekend);
110            self.elevators.swap_closed_cars(&mut self.crowd.closed_cars);
111            motion::advance(
112                &mut self.crowd,
113                chunk * CROWD_SECONDS_PER_MINUTE,
114                &mut self.tower,
115            );
116            left -= chunk;
117        }
118        let staff_jobs = self.crowd.take_staff_results();
119        for job in &staff_jobs {
120            self.on_housekeeper_result(job.unit_id, job.ok);
121        }
122        if !staff_jobs.is_empty() {
123            self.dispatch_housekeepers();
124        }
125        self.finish_construction_and_search();
126
127        let hour = self.clock.hour();
128        if hour != self.last_hour {
129            self.last_hour = hour;
130            self.on_hour();
131        }
132        let day = self.clock.day();
133        if day != self.last_day {
134            self.last_day = day;
135            self.on_day();
136        }
137    }
138
139    #[doc(hidden)]
140    pub fn on_hour(&mut self) {
141        self.on_hour_runs += 1;
142        self.update_presence();
143        let hour = self.clock.hour();
144        if hour == 8 {
145            self.hotel_checkout();
146        }
147        if (14..17).contains(&hour) {
148            self.hotel_late_checkout();
149        }
150        let shift = self.mode.housekeeping_shift();
151        if hour >= shift.start && hour < shift.end {
152            self.resolve_extermination();
153            self.dispatch_housekeepers();
154        }
155        self.update_satisfaction();
156        self.attempt_move_ins();
157        self.collect_traffic_income();
158        self.sample_elevator_util();
159        self.evaluate_star();
160    }
161
162    #[doc(hidden)]
163    pub fn on_day(&mut self) {
164        self.weather = weather_for(self.clock.day());
165        let period =
166            (self.clock.day() as f64 / self.clock.calendar.maint_period_days as f64).floor() as i64;
167        if period != self.last_month {
168            self.last_month = period;
169            self.collect_monthly_rent();
170            self.pay_maintenance();
171            self.roll_condo_relocations();
172        }
173        let q = self.clock.quarter();
174        if q != self.last_quarter {
175            self.last_quarter = q;
176            self.last_quarter_money = self.money;
177            self.collect_rent();
178        }
179        self.maybe_random_event();
180        self.maybe_vip_stay();
181        self.check_vip();
182        self.report_move_ins();
183        self.check_milestones();
184        self.nudge_stranded();
185        self.nudge_metro_platform();
186        self.nudge_service_shortfalls();
187        self.roll_over_retail_day();
188        // A rebooked film opens tonight, after the spent one's last count.
189        self.rebook_spent_films();
190        self.ledger.end_day();
191    }
192
193    /// Settles the bomb search up to now before any new Security office
194    /// joins it, so guards already out move on from the minutes they
195    /// finished, and an office opening late in a long step cannot take a
196    /// floor they reached first (or join a search that already ended).
197    /// A Security office that opens inside the step joins at the minute it
198    /// opened (Modern), after the search has run up to that minute, so a
199    /// long step plays out as shorter ones would. The clock is set back to
200    /// each opening only for that catch-up, then restored.
201    pub(crate) fn finish_construction_and_search(&mut self) {
202        if self.events.bomb.is_some() && self.mode.security_joins_bomb_search() {
203            let now = self.clock.minutes;
204            for at in self.security_openings_by(now) {
205                self.clock.minutes = at;
206                self.advance_bomb_search();
207                self.finish_construction();
208                self.clock.minutes = now;
209                if self.events.bomb.is_none() {
210                    break;
211                }
212            }
213        }
214        self.advance_bomb_search();
215        self.finish_construction();
216    }
217
218    /// The minutes at which Security offices still under construction open
219    /// by `now`, earliest first. Empty when nothing is being built.
220    fn security_openings_by(&self, now: f64) -> Vec<f64> {
221        let mut at: Vec<f64> = self
222            .constructing
223            .iter()
224            .filter_map(|id| self.tower.get_unit(*id))
225            .filter(|u| u.kind == Kind::Security && u.state == UnitState::Construction)
226            .filter_map(|u| u.extras().complete_at)
227            .filter(|t| *t <= now)
228            .collect();
229        at.sort_by(f64::total_cmp);
230        at
231    }
232
233    #[doc(hidden)]
234    pub fn finish_construction(&mut self) {
235        if self.constructing.is_empty() {
236            return;
237        }
238        let ids: Vec<i64> = self.constructing.iter().copied().collect();
239        for id in ids {
240            let Some(u) = self.tower.get_unit(id) else {
241                self.constructing.shift_remove(&id);
242                continue;
243            };
244            if u.state != UnitState::Construction {
245                self.constructing.shift_remove(&id);
246                continue;
247            }
248            // `minutes >= (u.completeAt ?? 0)`: a non-numeric value the save
249            // carried goes through `Number()`, so NaN never completes.
250            let threshold = match (u.extras().complete_at, &u.extras().complete_at_raw) {
251                (Some(v), _) => v,
252                (None, Some(raw)) => crate::load::js_number(Some(raw)),
253                (None, None) => 0.0,
254            };
255            if self.clock.minutes >= threshold {
256                let (name, floor, kind) = (u.kind.facility().name, u.floor, u.kind);
257                let mode = self.tower.mode;
258                let (u, at) = self.tower.marked_by_id(id).unwrap();
259                u.set_state(at, UnitState::Empty);
260                u.extras_mut().complete_at = None;
261                u.extras_mut().complete_at_raw = None;
262                let full_house = kind == Kind::Cinema && fills_blockbuster_slot(mode, u);
263                self.constructing.shift_remove(&id);
264                let msg = format!(
265                    "{} on {} is now open for business.",
266                    name,
267                    self.floor_label(floor)
268                );
269                self.emit(&msg, LogKind::Good);
270                if kind == Kind::Security {
271                    self.join_bomb_search(floor);
272                }
273                // A theater opens with its mode's first film free: a Classic
274                // new film fills the house tonight (#1932), so it is a
275                // blockbuster from its first evening rather than from the
276                // next rollover. A Modern theater opens with a feature.
277                if full_house && !self.blockbusters.contains(&(id as f64)) {
278                    self.blockbusters.push(id as f64);
279                }
280            }
281        }
282    }
283
284    #[doc(hidden)]
285    /// `floorLabel`: "floor N" above ground, "B{n}" below.
286    pub fn floor_label(&self, floor: i64) -> String {
287        if floor >= 1 {
288            format!("floor {floor}")
289        } else {
290            format!("B{}", 1 - floor)
291        }
292    }
293}