Skip to main content
Verticopolis engineGitHub

verticopolis_engine/
build.rs

1//! Placing and selling: [`Simulation::build`] for floors, lobbies and rooms,
2//! [`Simulation::build_transport`] for elevators, stairs and escalators, and
3//! [`Simulation::sell_at`]. Each build has a dry run
4//! ([`Simulation::can_build`], [`Simulation::can_build_transport`]) that
5//! answers with the same refusal and the full price, for a placement ghost.
6//!
7//! A refused command changes nothing and says why in a sentence a player can
8//! read. A placed one charges the money, logs the purchase and emits its
9//! gameplay events.
10
11use crate::clock::GameMode;
12use crate::facilities::*;
13use crate::gameplay::{GameplayEvent, RemovalMethod};
14use crate::sim::{LogKind, Simulation};
15use crate::tower::UnitState;
16
17/// What a build did.
18pub struct BuildResult {
19    /// Whether it was placed.
20    pub ok: bool,
21    /// Why it was refused, ready to show a player. `None` when placed.
22    pub reason: Option<String>,
23}
24
25impl BuildResult {
26    fn fail(reason: impl Into<String>) -> BuildResult {
27        BuildResult {
28            ok: false,
29            reason: Some(reason.into()),
30        }
31    }
32}
33
34/// The answer to a dry run: what the build would do, and its price.
35pub struct CanBuild {
36    /// Whether the build would be placed.
37    pub ok: bool,
38    /// Why it would be refused. `None` when it would be placed.
39    pub reason: Option<String>,
40    /// The full price, in dollars, floors and bridged tiles included. It is
41    /// set even when the build would be refused.
42    pub cost: f64,
43}
44
45/// Whether a tile of `kind` placed where there is no floor yet also pays for
46/// the floor it lays. A lobby does, as in the original: a bare lobby tile
47/// costs the lobby's price plus the floor's, and a lobby over a floor tile
48/// the lobby's price alone. The catalog publishes this as `paysForFloor`.
49pub fn pays_for_floor(kind: Kind) -> bool {
50    kind == Kind::Lobby
51}
52
53/// The price of one bridged substrate tile: a lobby tile when the placement is
54/// a lobby, a floor tile otherwise. A bridge fills only empty tiles, so a
55/// bridged tile of a kind that pays for its floor ([`pays_for_floor`]) adds
56/// the floor's price. Read from the facility table, the same prices the
57/// catalog quotes.
58fn substrate_cost(kind: Kind) -> f64 {
59    let substrate = if kind == Kind::Lobby {
60        Kind::Lobby
61    } else {
62        Kind::Floor
63    };
64    bare_tile_cost(substrate)
65}
66
67/// The price of a tile of `kind` placed where there is no floor yet.
68fn bare_tile_cost(kind: Kind) -> f64 {
69    let tile = kind.facility().cost;
70    if pays_for_floor(kind) {
71        tile + Kind::Floor.facility().cost
72    } else {
73        tile
74    }
75}
76
77/// Classic's refusal for any room but the wedding hall on floor 100.
78pub(crate) const CLASSIC_TOP_FLOOR_REASON: &str =
79    "Only the wedding hall can be built on floor 100.";
80
81/// The buried-treasure log line.
82pub(crate) fn treasure_message(gold: f64) -> String {
83    format!(
84        "๐Ÿ’ฐ Excavation crews unearthed buried treasure worth ${}!",
85        crate::services::with_thousands(gold)
86    )
87}
88
89impl Simulation {
90    /// Turns automatic floor bridging off or on, and returns the new setting.
91    /// Only a Modern game can turn it off; a Classic game always bridges and
92    /// this returns `true`.
93    pub fn toggle_auto_bridge(&mut self) -> bool {
94        if self.mode.bridging_toggleable() {
95            self.auto_bridge = !self.auto_bridge;
96        }
97        self.auto_bridge
98    }
99
100    #[doc(hidden)]
101    pub fn is_unlocked(&self, kind: Kind) -> bool {
102        let f = kind.facility();
103        f.available_in(self.mode) && self.star >= f.min_star
104    }
105
106    /// The price of the tile a structural placement lays at `(floor, x)`: a
107    /// lobby placed where there is no floor yet pays for that floor too
108    /// ([`pays_for_floor`]); a lobby over a floor tile turns it into lobby
109    /// for the lobby's price alone.
110    fn structure_tile_cost(&self, kind: Kind, floor: i64, x: i64) -> f64 {
111        if self.tower.has_structure(floor, x) {
112            kind.facility().cost
113        } else {
114            bare_tile_cost(kind)
115        }
116    }
117
118    /// The dry run [`build`](Simulation::build) makes first: whether a
119    /// `kind` at `(floor, x)` would be placed, why not, and its full price.
120    /// Changes nothing.
121    ///
122    /// ```
123    /// use verticopolis_engine::clock::GameMode;
124    /// use verticopolis_engine::facilities::Kind;
125    /// use verticopolis_engine::sim::Simulation;
126    ///
127    /// let sim = Simulation::new_game(1, GameMode::Classic);
128    /// let quote = sim.can_build(Kind::Lobby, 1, 100);
129    /// assert!(quote.ok);
130    /// assert!(quote.cost > 0.0);
131    /// ```
132    pub fn can_build(&self, kind: Kind, floor: i64, x: i64) -> CanBuild {
133        let kind = ground_floor_structure_kind(kind, floor);
134        let f = kind.facility();
135        if !self.is_unlocked(kind) {
136            let reason = if !f.available_in(self.mode) {
137                format!("{} is a Modern-only facility.", f.name)
138            } else {
139                format!("{} unlocks at {}โ˜….", f.name, f.min_star)
140            };
141            return CanBuild {
142                ok: false,
143                reason: Some(reason),
144                cost: f.cost,
145            };
146        }
147        let money_check = |cost: f64| CanBuild {
148            ok: self.money >= cost,
149            reason: if self.money >= cost {
150                None
151            } else {
152                Some("Not enough money.".into())
153            },
154            cost,
155        };
156        // `!isRoomKind(kind)`: floor, lobby and every transport kind take the
157        // structural branch, where `canPlace` refuses a transport by name.
158        if !kind.is_room() {
159            if !self.auto_bridge {
160                let c = self.tower.can_place(kind, floor, x);
161                if !c.ok {
162                    return CanBuild {
163                        ok: false,
164                        reason: c.reason,
165                        cost: f.cost,
166                    };
167                }
168                return money_check(self.structure_tile_cost(kind, floor, x));
169            }
170            let bridge = self.tower.bridge_fill_plan(kind, floor, x, 1, 1).len() as f64;
171            let c = self.tower.can_place(kind, floor, x);
172            if !c.ok {
173                let horizontal = if kind == Kind::Lobby {
174                    floor == 1
175                } else {
176                    floor < 2
177                };
178                let rescued = horizontal
179                    && bridge > 0.0
180                    && self
181                        .tower
182                        .can_place_structure_ignoring_support(kind, floor, x)
183                        .ok;
184                if !rescued {
185                    return CanBuild {
186                        ok: false,
187                        reason: c.reason,
188                        cost: f.cost,
189                    };
190                }
191            }
192            return money_check(
193                self.structure_tile_cost(kind, floor, x) + bridge * substrate_cost(kind),
194            );
195        }
196        let pre = self.tower.can_place_room_ignoring_floor(kind, floor, x);
197        if !pre.ok {
198            return CanBuild {
199                ok: false,
200                reason: pre.reason,
201                cost: f.cost,
202            };
203        }
204        // Floor slabs end at `MAX_FLOOR`: the wedding hall's upper stories stand above them.
205        let hgt = kind.floors().min(MAX_FLOOR + 1 - floor);
206        let missing = self.tower.missing_floor_count(floor, x, f.width, hgt);
207        if missing > 0 && !self.tower.span_connects(floor, x, f.width, hgt) {
208            let reason = if floor >= 2 {
209                "Rooms must sit on the floor below: no floating overhangs."
210            } else {
211                "Build next to the tower. You can't build in midair."
212            };
213            return CanBuild {
214                ok: false,
215                reason: Some(reason.into()),
216                cost: f.cost,
217            };
218        }
219        // Classic keeps floor 100 for the wedding hall alone. This sits after
220        // every shared refusal reason, so the TypeScript reasons still win where
221        // both engines refuse.
222        if self.mode == GameMode::Classic
223            && kind != Kind::WeddingHall
224            && floor <= MAX_FLOOR
225            && floor + kind.floors() > MAX_FLOOR
226        {
227            return CanBuild {
228                ok: false,
229                reason: Some(CLASSIC_TOP_FLOOR_REASON.into()),
230                cost: f.cost,
231            };
232        }
233        let bridge = if self.auto_bridge {
234            self.tower
235                .bridge_fill_plan(kind, floor, x, f.width, hgt)
236                .len() as i64
237        } else {
238            0
239        };
240        money_check(f.cost + ((missing + bridge) as f64) * Kind::Floor.facility().cost)
241    }
242
243    /// Places a floor tile, a lobby tile or a room with its left edge at
244    /// tile `x` and its bottom on `floor`, and charges for it.
245    ///
246    /// A room needs a floor under each of its tiles or bridges the gap when
247    /// [`auto_bridge`](Simulation::auto_bridge) is on, paying for the floor it
248    /// lays. Floors and lobbies are laid one tile at a time; to lay a run, call
249    /// [`paint_brush`](Simulation::paint_brush). A refused build changes
250    /// nothing. Floor 1 is the ground floor and basements are 0 and below.
251    ///
252    /// ```
253    /// use verticopolis_engine::clock::GameMode;
254    /// use verticopolis_engine::facilities::Kind;
255    /// use verticopolis_engine::sim::Simulation;
256    ///
257    /// let mut sim = Simulation::new_game(1, GameMode::Classic);
258    /// assert!(sim.build(Kind::Lobby, 1, 100).ok);
259    ///
260    /// // Offices open at one star, but not on a bare lot five floors up.
261    /// let refused = sim.build(Kind::Office, 5, 300);
262    /// assert!(!refused.ok);
263    /// println!("{}", refused.reason.unwrap());
264    /// ```
265    pub fn build(&mut self, kind: Kind, floor: i64, x: i64) -> BuildResult {
266        let kind = ground_floor_structure_kind(kind, floor);
267        let can = self.can_build(kind, floor, x);
268        if !can.ok {
269            return BuildResult {
270                ok: false,
271                reason: can.reason,
272            };
273        }
274        let f = kind.facility();
275        let hgt = kind.floors().min(MAX_FLOOR + 1 - floor);
276        let quoted_bridge = if self.auto_bridge {
277            self.tower
278                .bridge_fill_plan(kind, floor, x, f.width, hgt)
279                .len() as f64
280        } else {
281            0.0
282        };
283        if kind.is_room() {
284            if let Err(reason) = self.tower.ensure_floor_under(floor, x, f.width, hgt) {
285                return BuildResult::fail(reason);
286            }
287        }
288        let laid_bridge = if self.auto_bridge {
289            self.tower.fill_bridge(kind, floor, x, f.width, hgt)
290        } else {
291            Vec::new()
292        };
293        let res = self.tower.place(kind, floor, x);
294        if !res.ok {
295            self.tower.remove_units(&laid_bridge);
296            return BuildResult {
297                ok: false,
298                reason: res.reason,
299            };
300        }
301        let price = can.cost - (quoted_bridge - laid_bridge.len() as f64) * substrate_cost(kind);
302        self.money -= price;
303        self.note_edit_cash(-price);
304        if let Some(id) = res.unit_id {
305            if let Some(list) = kind.subtype_list() {
306                let name = *self.rng.pick(list);
307                self.tower.set_unit_subtype(id, Some(name));
308            }
309            if self.mode == GameMode::Classic {
310                if let Some(ladder) = crate::econ::classic_ladder(kind) {
311                    let cfg = crate::econ::rent_config(kind).unwrap();
312                    let v = ladder[crate::rent::AVERAGE_RUNG];
313                    self.tower.get_unit_mut(id).unwrap().rent =
314                        if v == cfg.default { None } else { Some(v) };
315                }
316            }
317            let dur = kind.build_minutes();
318            if dur > 0.0 {
319                let (u, at) = self.tower.marked_by_id(id).unwrap();
320                u.set_state(at, UnitState::Construction);
321                u.extras_mut().complete_at = Some(self.clock.minutes + dur);
322                self.constructing.insert(id);
323            }
324            if kind == Kind::WeddingHall {
325                self.emit(
326                    "Wedding Hall built! A VIP will inspect your tower soon.",
327                    LogKind::Good,
328                );
329                self.edit_vip_visit_day((self.clock.day() + 3) as f64);
330            }
331            if floor <= 0 && kind.is_room() {
332                let mut fresh = false;
333                for fl in floor..floor + hgt {
334                    for i in 0..f.width {
335                        let key = format!("{fl}:{}", x + i);
336                        if !self.excavated.contains(&key) {
337                            fresh = true;
338                            self.excavated.push(key);
339                        }
340                    }
341                }
342                // A search the player could undo would be free.
343                if fresh && self.treasures_found < 3.0 {
344                    self.note_treasure_search();
345                }
346                if fresh && self.treasures_found < 3.0 && self.rng.chance(0.18) {
347                    self.treasures_found += 1.0;
348                    let gold = 400_000.0 + self.rng.int(0, 200_000) as f64;
349                    self.money += gold;
350                    self.emit(&treasure_message(gold), LogKind::Money);
351                    // `triggerTreasure(floor, x + Math.floor(f.width / 2))`.
352                    self.fx.treasure = crate::sim::PointFx {
353                        floor,
354                        x: (x + f.width.div_euclid(2)) as f64,
355                        seq: self.fx.treasure.seq + 1,
356                    };
357                }
358            }
359        }
360        self.gameplay.push(GameplayEvent::FacilityPlaced {
361            kind,
362            floor: (floor + hgt - 1).min(MAX_FLOOR),
363            count: 1,
364        });
365        BuildResult {
366            ok: true,
367            reason: None,
368        }
369    }
370
371    /// The dry run [`build_transport`](Simulation::build_transport) makes
372    /// first: whether a shaft of `kind` at `x` spanning `bottom..=top` would
373    /// be placed, why not, and its full price. Changes nothing.
374    ///
375    /// An elevator costs the catalog `cost` plus `transportFloorCost` for
376    /// each floor past its first; stairs and escalators cost the catalog
377    /// `cost`. Refusals come in the build's order: not unlocked yet, then
378    /// money, then placement. The price is only a shaft's price for a
379    /// transport kind with `top` above `bottom`; anything else is refused.
380    pub fn can_build_transport(&self, kind: Kind, x: i64, bottom: i64, top: i64) -> CanBuild {
381        let f = kind.facility();
382        // Saturating, as are the placement checks, so an out-of-range span
383        // from a native host prices without overflowing: an elevator's price
384        // then outruns any ordinary balance, so the money check refuses it
385        // (failing that, the span rule does), and a fixed-price shaft reaches
386        // the placement rules. Any span the tower can hold is exact.
387        let span = top.saturating_sub(bottom);
388        let extra = if kind.is_elevator() {
389            span as f64 * crate::charges::TRANSPORT_FLOOR_COST
390        } else {
391            0.0
392        };
393        let cost = f.cost + extra;
394        let refuse = |reason: String| CanBuild {
395            ok: false,
396            reason: Some(reason),
397            cost,
398        };
399        if !self.is_unlocked(kind) {
400            return refuse(format!("{} unlocks at {}โ˜….", f.name, f.min_star));
401        }
402        if self.money < cost {
403            return refuse("Not enough money.".into());
404        }
405        let v = self.tower.validate_transport(kind, x, bottom, top);
406        if !v.ok {
407            return CanBuild {
408                ok: false,
409                reason: v.reason,
410                cost,
411            };
412        }
413        CanBuild {
414            ok: true,
415            reason: None,
416            cost,
417        }
418    }
419
420    /// Places an elevator, stairs or an escalator with its left edge at tile
421    /// `x`, serving floors `bottom` to `top`, and charges for it. A new
422    /// elevator starts with one car for every six floors it serves, up to its
423    /// kind's limit; [`add_car`](Simulation::add_car) adds more. Stairs and
424    /// escalators span exactly two floors.
425    ///
426    /// ```
427    /// use verticopolis_engine::clock::GameMode;
428    /// use verticopolis_engine::facilities::Kind;
429    /// use verticopolis_engine::sim::Simulation;
430    ///
431    /// let mut sim = Simulation::new_game(1, GameMode::Classic);
432    /// for x in 100..124 {
433    ///     sim.build(Kind::Lobby, 1, x);
434    /// }
435    /// for floor in 2..=4 {
436    ///     for x in 100..124 {
437    ///         sim.build(Kind::Floor, floor, x);
438    ///     }
439    /// }
440    /// let shaft = sim.build_transport(Kind::ElevatorStandard, 110, 1, 4);
441    /// assert!(shaft.ok, "{:?}", shaft.reason);
442    /// ```
443    pub fn build_transport(&mut self, kind: Kind, x: i64, bottom: i64, top: i64) -> BuildResult {
444        let quote = self.can_build_transport(kind, x, bottom, top);
445        if !quote.ok {
446            return BuildResult {
447                ok: false,
448                reason: quote.reason,
449            };
450        }
451        let res = self.tower.place_transport(kind, x, bottom, top);
452        if !res.ok {
453            return BuildResult {
454                ok: false,
455                reason: res.reason,
456            };
457        }
458        self.money -= quote.cost;
459        self.note_edit_cash(-quote.cost);
460        self.gameplay.push(GameplayEvent::FacilityPlaced {
461            kind,
462            floor: top,
463            count: 1,
464        });
465        BuildResult {
466            ok: true,
467            reason: None,
468        }
469    }
470
471    /// Sells whatever stands at a tile: a room first, then a shaft, then the
472    /// floor or lobby tile. Returns whether anything was sold. The refund and
473    /// the refusals are [`remove_facility`](Simulation::remove_facility)'s
474    /// with [`RemovalMethod::Sell`].
475    pub fn sell_at(&mut self, floor: i64, x: i64) -> bool {
476        let t = self.tower.transport_at(floor, x).map(|t| t.id);
477        let u = self.tower.unit_at(floor, x).map(|u| (u.id, u.kind));
478        let id = match (u, t) {
479            (Some((id, kind)), _) if !kind.is_structural() => id,
480            (_, Some(id)) => id,
481            (Some((id, _)), None) => id,
482            (None, None) => return false,
483        };
484        self.remove_facility(id, RemovalMethod::Sell).ok
485    }
486}
487
488#[path = "build_capacity.rs"]
489mod capacity;
490
491#[cfg(test)]
492#[path = "build_tests.rs"]
493mod tests;