Skip to main content
Verticopolis engineGitHub

verticopolis_engine/
sim.rs

1//! One game: [`Simulation`] holds a whole tower, its people, its money and
2//! its clock, and every player command and time step goes through it.
3//!
4//! ```
5//! use verticopolis_engine::clock::GameMode;
6//! use verticopolis_engine::facilities::Kind;
7//! use verticopolis_engine::sim::Simulation;
8//!
9//! let mut sim = Simulation::new_game(42, GameMode::Classic);
10//! for x in 100..124 {
11//!     sim.build(Kind::Lobby, 1, x);
12//! }
13//! sim.tick(60.0);
14//! assert!(sim.money < verticopolis_engine::sim::STARTING_MONEY);
15//! ```
16
17use indexmap::IndexSet;
18use serde_json::Value;
19
20use std::collections::HashMap;
21
22use crate::clock::{resolve_calendar, CalendarKind, Clock, GameMode};
23use crate::crowd::Crowd;
24use crate::dispatch::ElevatorDispatch;
25use crate::events::EventSystem;
26use crate::gameplay::{GameplayEvent, GameplayEvents};
27use crate::housekeeping::Housekeeping;
28use crate::ledger::Ledger;
29use crate::rng::Rng;
30use crate::sim_loop::{weather_for, Weather};
31use crate::telemetry::ElevatorTelemetry;
32use crate::tower::Tower;
33
34#[doc(hidden)]
35#[derive(Clone, Copy, Debug, Default)]
36pub struct MoveIns {
37    pub offices: i64,
38    pub condos: i64,
39    pub rooms: i64,
40    pub fitness: i64,
41    pub clinic: i64,
42    pub rentals: i64,
43}
44
45/// The save format version this engine writes. It loads every older version
46/// back to the oldest one [`crate::identity::identity`] names.
47pub const SAVE_VERSION: i64 = 7;
48/// The money a new game starts with.
49pub const STARTING_MONEY: f64 = 2_000_000.0;
50/// How many messages [`Simulation::log`] keeps; the oldest goes first.
51pub const LOG_RING_CAP: usize = 300;
52#[doc(hidden)]
53pub const LOG_SAVE_CAP: usize = LOG_RING_CAP;
54
55/// How the game styles a message in its log.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum LogKind {
58    /// News: a guest arrived, an event started.
59    Info,
60    /// Good news: a new star, a milestone.
61    Good,
62    /// Bad news: a fire, a tenant leaving.
63    Bad,
64    /// A payment or a charge.
65    Money,
66}
67
68impl LogKind {
69    /// The kind as a save writes it: `info`, `good`, `bad` or `money`.
70    pub fn as_str(self) -> &'static str {
71        match self {
72            LogKind::Info => "info",
73            LogKind::Good => "good",
74            LogKind::Bad => "bad",
75            LogKind::Money => "money",
76        }
77    }
78}
79
80/// One message in the game's log.
81#[derive(Clone, Debug)]
82pub struct LogEntry {
83    /// The game minute it was written ([`crate::clock::Clock::minutes`]).
84    pub minute: f64,
85    /// The message, in American English, ready to show a player.
86    pub text: String,
87    /// How to style it.
88    pub kind: LogKind,
89}
90
91#[doc(hidden)]
92/// A cosmetic event the renderer polls: `explosionFx` and `treasureFx`
93/// carry a point, `thiefFx` a floor and whether the thief was caught. The
94/// sequence numbers are what the renderer compares; nothing here is saved
95/// or hashed (`SimContext.trigger*`).
96#[derive(Clone, Copy, Debug, Default, PartialEq)]
97pub struct PointFx {
98    pub floor: i64,
99    pub x: f64,
100    pub seq: i64,
101}
102
103#[doc(hidden)]
104#[derive(Clone, Copy, Debug, PartialEq)]
105pub struct ThiefFx {
106    pub caught: bool,
107    pub floor: i64,
108    pub seq: i64,
109}
110
111#[doc(hidden)]
112#[derive(Clone, Copy, Debug, PartialEq)]
113pub struct FxState {
114    pub santa_seq: i64,
115    pub explosion: PointFx,
116    pub thief: ThiefFx,
117    pub treasure: PointFx,
118    pub vip_seq: i64,
119}
120
121impl Default for FxState {
122    fn default() -> FxState {
123        FxState {
124            santa_seq: 0,
125            explosion: PointFx::default(),
126            thief: ThiefFx {
127                caught: false,
128                floor: 1,
129                seq: 0,
130            },
131            treasure: PointFx::default(),
132            vip_seq: 0,
133        }
134    }
135}
136
137/// One game of Verticopolis.
138///
139/// Start one with [`Simulation::new_game`], or load one from a save with
140/// [`crate::load::deserialize`]. Advance it with [`Simulation::tick`] and
141/// change it only through its commands ([`build`](Simulation::build),
142/// [`add_car`](Simulation::add_car), [`adjust_rent`](Simulation::adjust_rent)
143/// and the rest), which apply the rules, charge the money and refuse what a
144/// player could not do. The public fields are for reading; writing one skips
145/// those rules.
146///
147/// A renderer reads the tower through [`crate::view`] or [`crate::frame`],
148/// and an analytics or achievements layer drains [`Simulation::gameplay`].
149pub struct Simulation {
150    #[doc(hidden)]
151    pub rng: Rng,
152    /// The game mode, fixed when the game starts.
153    pub mode: GameMode,
154    /// The calendar a Modern game runs on. A Classic game always runs the canon
155    /// calendar.
156    pub modern_calendar: CalendarKind,
157    /// Whether a new floor joins the floor next to it automatically. Only a
158    /// Modern game can turn this off ([`Simulation::toggle_auto_bridge`]).
159    pub auto_bridge: bool,
160    /// Whether the tower was started before saves carried a build stamp. The
161    /// web game shows these towers a founder badge.
162    pub founder: bool,
163    /// The game clock: day, hour, minute, quarter and year.
164    pub clock: Clock,
165    /// The people moving through the tower.
166    pub crowd: Crowd,
167    /// Emergencies, and the choice waiting for the player's answer.
168    pub events: EventSystem,
169    #[doc(hidden)]
170    pub ledger: Ledger,
171    /// The tower: its floors, rooms and transport. A renderer reads it through
172    /// [`crate::view`] instead of walking it.
173    pub tower: Tower,
174    /// The player's money, in dollars. It can go negative.
175    pub money: f64,
176    /// The tower's rating: 1 to 5 stars, and 6 once it is a TOWER.
177    pub star: i64,
178    #[doc(hidden)]
179    pub last_quarter_money: f64,
180    #[doc(hidden)]
181    /// `evaluatedTower`; `None` when the save carried no key.
182    pub evaluated_tower: Option<bool>,
183    #[doc(hidden)]
184    /// The day the VIP inspection is due, as the save carries it (a forged
185    /// save may hold a fraction, which the day comparisons then honor).
186    pub vip_visit_day: f64,
187    #[doc(hidden)]
188    pub vip_favorable: bool,
189    #[doc(hidden)]
190    pub vip_visits: i64,
191    #[doc(hidden)]
192    pub last_vip_nag_day: i64,
193    #[doc(hidden)]
194    pub treasures_found: f64,
195    #[doc(hidden)]
196    pub extermination_due_day: Option<f64>,
197    #[doc(hidden)]
198    /// The infested room ids a booking billed, saved while it is due. `None`
199    /// on a legacy save, which treats every infested room.
200    pub extermination_room_ids: Option<Vec<i64>>,
201    #[doc(hidden)]
202    /// `"floor:x"` keys in insertion order.
203    pub excavated: Vec<String>,
204    #[doc(hidden)]
205    /// The cinemas booked for a blockbuster this month, by id, as the save
206    /// carries them (a forged save may hold a fraction).
207    pub blockbusters: Vec<f64>,
208    /// The milestone ids the tower has reached, in the order it reached them.
209    /// The ids are stable across versions.
210    pub milestones: Vec<String>,
211    /// The recent messages, oldest first, at most [`LOG_RING_CAP`].
212    /// [`crate::frame::log_since`] returns the ones after a sequence number.
213    pub log: Vec<LogEntry>,
214    #[doc(hidden)]
215    pub log_seq: i64,
216    #[doc(hidden)]
217    /// Unit ids under construction, in insertion order.
218    pub constructing: IndexSet<i64>,
219    // ---- transient loop state (never saved) ----
220    #[doc(hidden)]
221    pub elevators: ElevatorDispatch,
222    #[doc(hidden)]
223    /// `elevatorUtil` / `elevatorHourly` / `elevatorOrigins` (never saved).
224    pub telemetry: ElevatorTelemetry,
225    #[doc(hidden)]
226    pub housekeeping: Housekeeping,
227    #[doc(hidden)]
228    pub weather: Weather,
229    #[doc(hidden)]
230    /// The cosmetic event counters the renderer polls (never saved).
231    pub fx: FxState,
232    #[doc(hidden)]
233    pub last_hour: i64,
234    #[doc(hidden)]
235    pub last_day: i64,
236    #[doc(hidden)]
237    pub last_month: i64,
238    #[doc(hidden)]
239    pub last_quarter: i64,
240    #[doc(hidden)]
241    pub on_hour_runs: i64,
242    #[doc(hidden)]
243    pub move_ins_today: MoveIns,
244    #[doc(hidden)]
245    pub waste_nudged: bool,
246    #[doc(hidden)]
247    pub suite_parking_nudged: bool,
248    #[doc(hidden)]
249    pub stranded_nudged: bool,
250    #[doc(hidden)]
251    pub metro_platform_nudged: bool,
252    #[doc(hidden)]
253    /// `floorReachable` memo keyed by tower revision.
254    pub reach_memo: (i64, HashMap<i64, bool>),
255    #[doc(hidden)]
256    /// `noiseMemo`: unit id to `noiseAfflicted`, keyed by tower revision.
257    pub noise_memo: (i64, HashMap<i64, bool>),
258    #[doc(hidden)]
259    /// The inspector's demand map memo (never saved; see
260    /// `diagnostics/memo.rs`).
261    pub diag_memo: crate::diagnostics::DiagMemo,
262    #[doc(hidden)]
263    /// The saved camera view (inert cargo the save carries).
264    pub view: Option<Value>,
265    /// The gameplay events since the host last drained them. Call
266    /// `sim.gameplay.drain()` after each frame. Never saved.
267    pub gameplay: GameplayEvents,
268    #[doc(hidden)]
269    /// The open extend gesture (#990) as `(id, bottom, top)`: the shaft and
270    /// the furthest span the gesture has paid for (never saved, and not in
271    /// the state view; see `begin_extend`).
272    pub extend_gesture: Option<(i64, i64, i64)>,
273}
274
275impl Simulation {
276    #[doc(hidden)]
277    // `new Simulation(seed, mode, modernCalendar, startUnbridged)`.
278    pub fn new(
279        seed: u32,
280        mode: GameMode,
281        modern_calendar: CalendarKind,
282        start_unbridged: bool,
283    ) -> Simulation {
284        let bridging_toggleable = mode == GameMode::Modern;
285        let modern_calendar = if mode == GameMode::Classic {
286            CalendarKind::RealWorld
287        } else {
288            modern_calendar
289        };
290        Simulation {
291            rng: Rng::new(seed),
292            mode,
293            modern_calendar,
294            auto_bridge: if bridging_toggleable {
295                !start_unbridged
296            } else {
297                true
298            },
299            founder: false,
300            clock: Clock::new(0.0, resolve_calendar(mode, modern_calendar)),
301            crowd: Crowd::new(seed),
302            events: EventSystem::new(seed),
303            ledger: Ledger::new(),
304            tower: {
305                let mut t = Tower::new();
306                t.allows_escalator_on_office_floors = mode == GameMode::Modern;
307                t.mode = mode;
308                t
309            },
310            money: STARTING_MONEY,
311            star: 1,
312            last_quarter_money: 0.0,
313            evaluated_tower: Some(false),
314            vip_visit_day: -1.0,
315            vip_favorable: false,
316            vip_visits: 0,
317            last_vip_nag_day: -100,
318            treasures_found: 0.0,
319            extermination_due_day: None,
320            excavated: Vec::new(),
321            blockbusters: Vec::new(),
322            milestones: Vec::new(),
323            log: Vec::new(),
324            log_seq: 0,
325            constructing: IndexSet::new(),
326            elevators: ElevatorDispatch::new(),
327            telemetry: ElevatorTelemetry::default(),
328            housekeeping: Housekeeping::default(),
329            weather: weather_for(0),
330            fx: FxState::default(),
331            last_hour: -1,
332            last_day: 0,
333            last_month: -1,
334            last_quarter: -1,
335            on_hour_runs: 0,
336            move_ins_today: MoveIns::default(),
337            waste_nudged: false,
338            suite_parking_nudged: false,
339            stranded_nudged: false,
340            metro_platform_nudged: false,
341            extermination_room_ids: None,
342            reach_memo: (-1, HashMap::new()),
343            noise_memo: (-1, HashMap::new()),
344            diag_memo: Default::default(),
345            view: None,
346            gameplay: GameplayEvents::default(),
347            extend_gesture: None,
348        }
349    }
350
351    #[doc(hidden)]
352    /// `recordMoney(cat, amount)`.
353    pub fn record_money(&mut self, cat: &'static str, amount: f64) {
354        self.ledger.record(cat, amount);
355    }
356
357    /// How many people live and work in the tower: the number the star
358    /// ratings count.
359    pub fn population(&self) -> i64 {
360        self.tower.total_population()
361    }
362
363    #[doc(hidden)]
364    /// `floorReachable(floor)`: memoized per tower revision, no rng.
365    pub fn floor_reachable(&mut self, floor: i64) -> bool {
366        if floor == 1 {
367            return true;
368        }
369        if self.reach_memo.0 != self.tower.revision {
370            self.reach_memo = (self.tower.revision, HashMap::new());
371        }
372        if let Some(&hit) = self.reach_memo.1.get(&floor) {
373            return hit;
374        }
375        let hit = self.crowd.floor_reachable(&self.tower, floor);
376        self.reach_memo.1.insert(floor, hit);
377        hit
378    }
379
380    #[doc(hidden)]
381    /// `positionReachable(floor, x)`.
382    pub fn position_reachable(&mut self, floor: i64, x: i64) -> bool {
383        if floor == 1 {
384            return true;
385        }
386        if self.tower.segments_of(floor).len() <= 1 {
387            return self.floor_reachable(floor);
388        }
389        self.crowd.position_reachable(&self.tower, floor, x)
390    }
391
392    #[doc(hidden)]
393    /// `unitReachable` (sim/demand.ts).
394    pub fn unit_reachable(&mut self, floor: i64, x: i64) -> bool {
395        self.position_reachable(floor, x)
396    }
397
398    /// Starts a new game: an empty lot, [`STARTING_MONEY`], 07:00 on day 0.
399    ///
400    /// The same `seed` and the same commands give the same game. A Modern
401    /// game gets the real-world calendar and automatic floor bridging; use
402    /// [`Simulation::new_game_with`] to choose them.
403    ///
404    /// ```
405    /// use verticopolis_engine::clock::GameMode;
406    /// use verticopolis_engine::sim::Simulation;
407    ///
408    /// let sim = Simulation::new_game(7, GameMode::Modern);
409    /// assert_eq!(sim.star, 1);
410    /// assert_eq!(sim.population(), 0);
411    /// ```
412    pub fn new_game(seed: u32, mode: GameMode) -> Simulation {
413        Simulation::new_game_with(seed, mode, CalendarKind::RealWorld, false)
414    }
415
416    /// Starts a new game with a choice of calendar and bridging. Both apply
417    /// to a Modern game only: a Classic game always runs the canon calendar
418    /// with bridging on, whatever these say.
419    ///
420    /// ```
421    /// use verticopolis_engine::clock::{CalendarKind, GameMode};
422    /// use verticopolis_engine::sim::Simulation;
423    ///
424    /// let sim = Simulation::new_game_with(7, GameMode::Modern, CalendarKind::Canon, true);
425    /// assert!(!sim.auto_bridge);
426    /// ```
427    pub fn new_game_with(
428        seed: u32,
429        mode: GameMode,
430        modern_calendar: CalendarKind,
431        start_unbridged: bool,
432    ) -> Simulation {
433        let mut sim = Simulation::new(seed, mode, modern_calendar, start_unbridged);
434        sim.emit(
435            "Welcome! Lay a lobby on the ground line to open your tower.",
436            LogKind::Info,
437        );
438        sim.gameplay
439            .push(GameplayEvent::TowerFounded { mode: sim.mode });
440        sim
441    }
442
443    #[doc(hidden)]
444    /// `sim.emit(text, kind)`: append to the ring, no dedupe.
445    pub fn emit(&mut self, text: &str, kind: LogKind) {
446        self.log.push(LogEntry {
447            minute: self.clock.minutes,
448            text: text.to_string(),
449            kind,
450        });
451        self.log_seq += 1;
452        if self.log.len() > LOG_RING_CAP {
453            self.log.remove(0);
454        }
455    }
456}