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;