Skip to main content
Verticopolis engineGitHub

verticopolis_engine/
charges.rs

1//! Commands that cost or refund money after a build: elevator cars
2//! ([`Simulation::add_car`], [`Simulation::remove_car`]), stretching a shaft
3//! ([`Simulation::extend_transport`]) and removing a room or a shaft
4//! ([`Simulation::remove_facility`]).
5//!
6//! Each one checks, changes the tower and moves the money in one step and
7//! reports the amount, so a frontend sends the command and never writes
8//! money itself.
9
10use crate::churn::household_price;
11use crate::econ::rent_config;
12use crate::facilities::Kind;
13use crate::gameplay::{GameplayEvent, RemovalMethod};
14use crate::services::with_thousands;
15use crate::sim::{LogKind, Simulation};
16use crate::tower::UnitState;
17
18/// The price of one more elevator car.
19pub const ADD_CAR_COST: f64 = 40_000.0;
20/// The price of each floor an elevator is built or extended past its first.
21pub const TRANSPORT_FLOOR_COST: f64 = 5_000.0;
22
23#[doc(hidden)]
24pub const NOT_ENOUGH_MONEY: &str = "Not enough money.";
25#[doc(hidden)]
26pub const ELEVATOR_GONE: &str = "That elevator is gone.";
27#[doc(hidden)]
28pub const ONLY_ELEVATOR_CARS: &str = "Only elevators have cars.";
29#[doc(hidden)]
30pub const ONLY_ELEVATOR_EXTEND: &str = "Only elevators can be extended.";
31#[doc(hidden)]
32pub const CAR_LIMIT: &str = "This elevator has all the cars it can hold.";
33#[doc(hidden)]
34pub const LAST_CAR: &str = "An elevator needs at least one car.";
35#[doc(hidden)]
36pub const FACILITY_GONE: &str = "That facility is gone.";
37#[doc(hidden)]
38pub const HOUSEKEEPING_PERMANENT: &str =
39    "Housekeeping rooms are permanent. The 1994 game does not let you remove them.";
40#[doc(hidden)]
41/// Classic never lets the wedding hall be sold or bulldozed.
42pub const CLASSIC_WEDDING_HALL_KEPT: &str = "The wedding hall can't be removed.";
43#[doc(hidden)]
44pub const SECURITY_PERMANENT: &str =
45    "Security offices are permanent. The 1994 game does not let you remove them.";
46#[doc(hidden)]
47pub const RECYCLING_PERMANENT: &str =
48    "Recycling centers are permanent. The 1994 game does not let you remove them.";
49#[doc(hidden)]
50pub const CINEMA_PERMANENT: &str =
51    "Movie theaters are permanent. The 1994 game does not let you remove them.";
52
53/// The refusal for a Classic room `GameMode::room_is_permanent` keeps.
54fn permanent_room_refusal(kind: Kind) -> &'static str {
55    match kind {
56        Kind::Security => SECURITY_PERMANENT,
57        Kind::Recycling => RECYCLING_PERMANENT,
58        Kind::Cinema => CINEMA_PERMANENT,
59        Kind::WeddingHall => CLASSIC_WEDDING_HALL_KEPT,
60        _ => HOUSEKEEPING_PERMANENT,
61    }
62}
63/// The refusal for a malformed command, such as a brush with too many tiles.
64/// The JavaScript binding also gives it for an id or floor that is not a
65/// whole number, or an unknown name; a Rust host passes typed values and
66/// never sees those cases.
67pub const INVALID_REQUEST: &str = "That request is not valid.";
68
69#[doc(hidden)]
70/// A whole number a command may take, as TypeScript's `Number.isSafeInteger`
71/// reads it: finite, no fraction, within 2^53 - 1. NaN and infinities fail.
72pub fn whole(x: f64) -> Option<i64> {
73    (x.fract() == 0.0 && x.abs() <= 9_007_199_254_740_991.0).then_some(x as i64)
74}
75
76#[doc(hidden)]
77/// `carResaleRefund()`: half the add-car cost.
78pub fn car_resale_refund() -> f64 {
79    (ADD_CAR_COST * 0.5).floor()
80}
81
82/// What a charged command did.
83#[derive(Clone, Debug, PartialEq)]
84pub struct ChargeResult {
85    /// Whether it was carried out.
86    pub ok: bool,
87    /// Why it was refused, ready to show a player. `None` when carried out.
88    pub reason: Option<String>,
89    /// The change to the player's money: negative for a charge, positive for
90    /// a refund, 0 when refused.
91    pub delta: f64,
92}
93
94impl ChargeResult {
95    pub(crate) fn refuse(reason: impl Into<String>) -> ChargeResult {
96        ChargeResult {
97            ok: false,
98            reason: Some(reason.into()),
99            delta: 0.0,
100        }
101    }
102
103    fn paid(delta: f64) -> ChargeResult {
104        ChargeResult {
105            ok: true,
106            reason: None,
107            delta,
108        }
109    }
110}
111
112/// What an extension did: the charge, and the shaft's span afterwards.
113#[derive(Clone, Debug, PartialEq)]
114pub struct ExtendResult {
115    /// The money moved, or the refusal.
116    pub charge: ChargeResult,
117    /// The shaft's lowest floor afterwards.
118    pub bottom: i64,
119    /// The shaft's highest floor afterwards.
120    pub top: i64,
121    /// How many floors this call billed.
122    pub added: i64,
123}
124
125/// Which end of a shaft an extension moves.
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub enum ExtendEnd {
128    /// Move the top floor.
129    Up,
130    /// Move the bottom floor.
131    Down,
132}
133
134impl ExtendEnd {
135    /// `up` or `down`.
136    pub fn parse(s: &str) -> Option<ExtendEnd> {
137        match s {
138            "up" => Some(ExtendEnd::Up),
139            "down" => Some(ExtendEnd::Down),
140            _ => None,
141        }
142    }
143}
144
145#[doc(hidden)]
146/// `extendBill`: one step of budget-clamped billing for an elevator extend.
147/// Returns the new ends and the count of floors past the high-water mark.
148pub fn extend_bill(
149    cur: (i64, i64),
150    hwm: (i64, i64),
151    end: ExtendEnd,
152    target_floor: i64,
153    money: f64,
154    per_floor: f64,
155) -> (i64, i64, i64) {
156    let (mut nb, mut nt) = cur;
157    match end {
158        ExtendEnd::Up => nt = (cur.0 + 1).max(target_floor),
159        ExtendEnd::Down => nb = (cur.1 - 1).min(target_floor),
160    }
161    let budget = (money / per_floor).floor().max(0.0);
162    // The budget is a whole count of floors; a balance too large for i64
163    // saturates, which still pays for any span the tower can hold.
164    let budget = if budget > i64::MAX as f64 {
165        i64::MAX
166    } else {
167        budget as i64
168    };
169    if nt > hwm.1 {
170        nt = hwm.1 + (nt - hwm.1).min(budget);
171    }
172    if nb < hwm.0 {
173        nb = hwm.0 - (hwm.0 - nb).min(budget);
174    }
175    let added = (nt - hwm.1).max(0) + (hwm.0 - nb).max(0);
176    (nb, nt, added)
177}
178
179impl Simulation {
180    /// The shaft behind an elevator command: `(kind, cars, bottom, top)`, or
181    /// the refusal to return.
182    fn elevator(&self, id: i64, not_elevator: &str) -> Result<(Kind, i64, i64, i64), ChargeResult> {
183        let Some(t) = self.tower.get_transport(id) else {
184            return Err(ChargeResult::refuse(ELEVATOR_GONE));
185        };
186        if !t.kind.is_elevator() {
187            return Err(ChargeResult::refuse(not_elevator));
188        }
189        Ok((t.kind, t.cars, t.bottom, t.top))
190    }
191
192    /// Buys one more car for elevator `id` at [`ADD_CAR_COST`]. Refused at the
193    /// elevator's car limit and when the money is short.
194    pub fn add_car(&mut self, id: i64) -> ChargeResult {
195        let (kind, cars, _, _) = match self.elevator(id, ONLY_ELEVATOR_CARS) {
196            Ok(t) => t,
197            Err(r) => return r,
198        };
199        if cars >= kind.max_cars() {
200            return ChargeResult::refuse(CAR_LIMIT);
201        }
202        if self.money < ADD_CAR_COST {
203            return ChargeResult::refuse(NOT_ENOUGH_MONEY);
204        }
205        if !self.set_cars(id, cars + 1) {
206            return ChargeResult::refuse(CAR_LIMIT);
207        }
208        self.money -= ADD_CAR_COST;
209        self.note_edit_cash(-ADD_CAR_COST);
210        ChargeResult::paid(-ADD_CAR_COST)
211    }
212
213    /// Sells one car of elevator `id` back for half of [`ADD_CAR_COST`].
214    /// Refused on the last car.
215    pub fn remove_car(&mut self, id: i64) -> ChargeResult {
216        let (_, cars, _, _) = match self.elevator(id, ONLY_ELEVATOR_CARS) {
217            Ok(t) => t,
218            Err(r) => return r,
219        };
220        if cars <= 1 || !self.set_cars(id, cars - 1) {
221            return ChargeResult::refuse(LAST_CAR);
222        }
223        let refund = car_resale_refund();
224        self.money += refund;
225        self.note_edit_cash(refund);
226        ChargeResult::paid(refund)
227    }
228
229    /// Starts a drag that stretches elevator `id`. Until
230    /// [`end_extend`](Simulation::end_extend), the
231    /// [`extend_transport`](Simulation::extend_transport) calls bill each floor
232    /// once, however far the player drags back and forth. Returns `false`
233    /// when `id` is not an elevator.
234    pub fn begin_extend(&mut self, id: i64) -> bool {
235        self.extend_gesture = None;
236        match self.elevator(id, ONLY_ELEVATOR_EXTEND) {
237            Ok((_, _, bottom, top)) => {
238                self.extend_gesture = Some((id, bottom, top));
239                true
240            }
241            Err(_) => false,
242        }
243    }
244
245    /// A host handing over its open gesture with a load (#990): kept as given
246    /// when `id` names an elevator (the shafts `beginExtend` accepts), and
247    /// widened to the span at use like any gesture. Not a binding command:
248    /// a caller that can hand over a mark here can hand over a whole save.
249    #[cfg_attr(not(feature = "wasm"), allow(dead_code))]
250    pub(crate) fn restore_extend(&mut self, id: i64, bottom: i64, top: i64) {
251        self.extend_gesture = self
252            .elevator(id, ONLY_ELEVATOR_EXTEND)
253            .ok()
254            .map(|_| (id, bottom, top));
255    }
256
257    /// Ends the drag [`begin_extend`](Simulation::begin_extend) started.
258    pub fn end_extend(&mut self) {
259        self.extend_gesture = None;
260    }
261
262    /// Moves one end of elevator `id` toward `target_floor`, charging
263    /// [`TRANSPORT_FLOOR_COST`] for each new floor. It grows only as far as
264    /// the money pays for. During a drag, a floor already paid for is not
265    /// billed again.
266    ///
267    /// ```
268    /// use verticopolis_engine::charges::ExtendEnd;
269    /// use verticopolis_engine::clock::GameMode;
270    /// use verticopolis_engine::facilities::Kind;
271    /// use verticopolis_engine::sim::Simulation;
272    ///
273    /// let mut sim = Simulation::new_game(1, GameMode::Classic);
274    /// for floor in 1..=6 {
275    ///     let kind = if floor == 1 { Kind::Lobby } else { Kind::Floor };
276    ///     for x in 100..124 {
277    ///         sim.build(kind, floor, x);
278    ///     }
279    /// }
280    /// sim.build_transport(Kind::ElevatorStandard, 110, 1, 3);
281    /// let id = sim.tower.transports[0].id;
282    ///
283    /// sim.begin_extend(id);
284    /// let grown = sim.extend_transport(id, ExtendEnd::Up, 6);
285    /// sim.end_extend();
286    /// assert_eq!((grown.bottom, grown.top, grown.added), (1, 6, 3));
287    /// ```
288    pub fn extend_transport(&mut self, id: i64, end: ExtendEnd, target_floor: i64) -> ExtendResult {
289        let (_, _, bottom, top) = match self.elevator(id, ONLY_ELEVATOR_EXTEND) {
290            Ok(t) => t,
291            Err(charge) => {
292                let (bottom, top) = self
293                    .tower
294                    .get_transport(id)
295                    .map_or((0, 0), |t| (t.bottom, t.top));
296                return ExtendResult {
297                    charge,
298                    bottom,
299                    top,
300                    added: 0,
301                };
302            }
303        };
304        let gesture = self
305            .extend_gesture
306            .filter(|&(gid, _, _)| gid == id)
307            .map(|(_, b, t)| (b, t));
308        let (hb, ht) = gesture.unwrap_or((bottom, top));
309        let mark = (hb.min(bottom), ht.max(top));
310        let (nb, nt, added) = extend_bill(
311            (bottom, top),
312            mark,
313            end,
314            target_floor,
315            self.money,
316            TRANSPORT_FLOOR_COST,
317        );
318        let unchanged = |charge: ChargeResult| ExtendResult {
319            charge,
320            bottom,
321            top,
322            added: 0,
323        };
324        if nb == bottom && nt == top {
325            let wanted = match end {
326                ExtendEnd::Up => target_floor > mark.1,
327                ExtendEnd::Down => target_floor < mark.0,
328            };
329            return unchanged(if wanted {
330                ChargeResult::refuse(NOT_ENOUGH_MONEY)
331            } else {
332                ChargeResult::paid(0.0)
333            });
334        }
335        let r = self.resize_transport(id, nb, nt);
336        if !r.ok {
337            return unchanged(ChargeResult {
338                ok: false,
339                reason: r.reason,
340                delta: 0.0,
341            });
342        }
343        let cost = added as f64 * TRANSPORT_FLOOR_COST;
344        self.money -= cost;
345        self.note_edit_cash(-cost);
346        if gesture.is_some() {
347            self.extend_gesture = Some((id, mark.0.min(nb), mark.1.max(nt)));
348        }
349        ExtendResult {
350            charge: ChargeResult::paid(if cost == 0.0 { 0.0 } else { -cost }),
351            bottom: nb,
352            top: nt,
353            added,
354        }
355    }
356
357    /// Removes room or shaft `id` and refunds half its build price (nothing
358    /// for a room a fire gutted). `method` says whether the player sold it or
359    /// bulldozed it, for the gameplay event. Refused for a burning room and
360    /// for a room the mode keeps for good. Rooms and shafts share one id
361    /// sequence, so `id` names exactly one thing.
362    pub fn remove_facility(&mut self, id: i64, method: RemovalMethod) -> ChargeResult {
363        if let Some(u) = self.tower.get_unit(id) {
364            let (kind, state) = (u.kind, u.state);
365            if state == UnitState::Fire {
366                return ChargeResult::refuse(format!(
367                    "You can't {} a burning unit. Call fire rescue or let it burn out.",
368                    method.as_str()
369                ));
370            }
371            if let Some(reason) = self.tower.removal_reason(id) {
372                return ChargeResult::refuse(reason);
373            }
374            if self.stays_for_good(kind, state) {
375                return ChargeResult::refuse(permanent_room_refusal(kind));
376            }
377            let refund = if state == UnitState::Gutted {
378                0.0
379            } else {
380                kind.resale_refund()
381            };
382            // A sold condo belongs to its household: removing it buys the
383            // unit back at the price it sold for, as an owner leaving does
384            // (`vacate`), before the shell's resale refund (#713).
385            let buyback = self.sold_condo_buyback(id, state);
386            if buyback > 0.0 && self.money + refund < buyback {
387                return ChargeResult::refuse(format!(
388                    "You can't {} this condo without buying it back for ${}.",
389                    method.as_str(),
390                    with_thousands(buyback)
391                ));
392            }
393            let floor = self.tower.get_unit(id).map(|u| u.floor).unwrap_or(0);
394            self.tower.remove_unit(id);
395            if buyback > 0.0 {
396                let msg = format!(
397                    "You bought back Condominium on {} for ${} to remove it.",
398                    self.floor_label(floor),
399                    with_thousands(buyback)
400                );
401                self.emit(&msg, LogKind::Money);
402            }
403            let refund = refund - buyback;
404            self.money += refund;
405            self.note_edit_cash(refund);
406            if kind == Kind::WeddingHall
407                && self.tower.built_wedding_hall != Some(true)
408                && self.evaluated_tower != Some(true)
409            {
410                self.edit_vip_visit_day(-1.0);
411            }
412            self.gameplay
413                .push(GameplayEvent::FacilityRemoved { kind, method });
414            return ChargeResult::paid(refund);
415        }
416        let Some(kind) = self.tower.get_transport(id).map(|t| t.kind) else {
417            return ChargeResult::refuse(FACILITY_GONE);
418        };
419        self.tower.remove_transport(id);
420        let refund = kind.resale_refund();
421        self.money += refund;
422        self.note_edit_cash(refund);
423        self.gameplay
424            .push(GameplayEvent::FacilityRemoved { kind, method });
425        ChargeResult::paid(refund)
426    }
427
428    /// What removing unit `id` costs to buy back: the price a sold condo's
429    /// household paid (scaled by its size, as `vacate` buys it back), or
430    /// nothing for any other room, an unsold condo or a gutted shell.
431    fn sold_condo_buyback(&self, id: i64, state: UnitState) -> f64 {
432        match self.tower.get_unit(id) {
433            Some(u) if u.kind == Kind::Condo && u.ever_occupied && state != UnitState::Gutted => {
434                let sale_price = u
435                    .rent
436                    .unwrap_or_else(|| rent_config(Kind::Condo).map(|c| c.default).unwrap_or(0.0));
437                household_price(sale_price, u.residents)
438            }
439            _ => 0.0,
440        }
441    }
442
443    /// Whether the tower keeps a room of `kind` in `state` for good, so no
444    /// removal takes it: a Classic permanent room stays unless a fire gutted
445    /// it, as a burned-out shell can still be cleared and the tile is not
446    /// lost.
447    pub(crate) fn stays_for_good(&self, kind: Kind, state: UnitState) -> bool {
448        state != UnitState::Gutted && self.mode.room_is_permanent(kind)
449    }
450}
451
452#[cfg(test)]
453#[path = "charges_tests.rs"]
454mod tests;
455
456#[cfg(test)]
457#[path = "charges_removal_tests.rs"]
458mod removal_tests;
459
460#[cfg(test)]
461#[path = "charges_schedule_tests.rs"]
462mod schedule_tests;