Skip to main content
Verticopolis engineGitHub

verticopolis_engine/
rent.rs

1//! Setting what rooms charge: one room at a time
2//! ([`Simulation::adjust_rent`], [`Simulation::price_unit`],
3//! [`Simulation::set_no_rate`]) or every room of a kind at once
4//! ([`Simulation::apply_rent_batch`]).
5//!
6//! In Classic a priced room picks one of four rungs (very low, low, average,
7//! high), as in the original. In Modern it takes any price inside its kind's
8//! band, in steps. [`crate::catalog::catalog`] publishes the rungs, bands and
9//! defaults for each kind. A condo that has been sold keeps its price.
10
11use crate::clock::GameMode;
12use crate::econ::{classic_ladder, rent_config, rent_of};
13use crate::facilities::Kind;
14use crate::gameplay::GameplayEvent;
15use crate::sim::Simulation;
16use crate::tower::Unit;
17
18/// The Average rung of a Classic price ladder (four rungs: Very Low, Low,
19/// Average, High): the Classic catalog's default price (`catalog_rent_default`),
20/// the mode's neutral price, and where the editor's picker puts a price that
21/// is not a finite number.
22pub(crate) const AVERAGE_RUNG: usize = 2;
23
24#[doc(hidden)]
25pub fn snap_to_ladder(rungs: &[f64; 4], value: f64) -> f64 {
26    if !value.is_finite() {
27        return rungs[AVERAGE_RUNG];
28    }
29    let mut best = rungs[0];
30    let mut best_dist = f64::INFINITY;
31    for &r in rungs {
32        let d = (value - r).abs();
33        if d <= best_dist {
34            best_dist = d;
35            best = r;
36        }
37    }
38    best
39}
40
41/// `ladderRungFor(...).level`.
42pub(crate) fn ladder_level(rungs: &[f64; 4], value: f64) -> usize {
43    let snapped = snap_to_ladder(rungs, value);
44    rungs
45        .iter()
46        .position(|&r| r == snapped)
47        .unwrap_or(AVERAGE_RUNG)
48}
49
50/// `priceOptions(kind)`: `Some(Some(ladder))` in Classic for the canon kinds,
51/// `Some(None)` for a Modern band, `None` for an unpriced kind.
52pub(crate) fn price_options(mode: GameMode, kind: Kind) -> Option<Option<[f64; 4]>> {
53    match mode {
54        GameMode::Classic => classic_ladder(kind).map(Some),
55        GameMode::Modern => rent_config(kind).map(|_| None),
56    }
57}
58
59fn price_neutral(mode: GameMode, kind: Kind) -> Option<f64> {
60    match price_options(mode, kind)? {
61        Some(l) => Some(l[AVERAGE_RUNG]),
62        None => Some(rent_config(kind)?.default),
63    }
64}
65
66/// Whether an effective price is the neutral price: an exact match, the test
67/// the batch reprice's "on the default" makes and the Map's Average band tier
68/// shares. A price off the neutral price by any amount is not neutral.
69pub(crate) fn is_neutral_price(price: f64, neutral: f64) -> bool {
70    price == neutral
71}
72
73/// The price [`Simulation::apply_rent_batch`] sets.
74#[derive(Clone, Copy, Debug, PartialEq)]
75pub enum BatchTarget {
76    /// The kind's default price: the average rung in Classic.
77    Default,
78    /// Off the market: the rooms take no new tenants (Classic only).
79    NoRate,
80    /// This price, snapped to a rung in Classic or kept inside the band in
81    /// Modern.
82    Price(f64),
83}
84
85/// What a batch reprice did, counted in rooms.
86#[derive(Clone, Debug, Default, PartialEq, Eq)]
87pub struct BatchRentResult {
88    /// Rooms of the kind.
89    pub matched: i64,
90    /// Rooms the batch applied to (`matched` less the two skips).
91    pub eligible: i64,
92    /// Rooms whose price actually moved.
93    pub changed: i64,
94    /// Sold condos, which keep their price.
95    pub skipped_sold: i64,
96    /// Rooms skipped for having a price of their own, when the batch asked
97    /// for default-priced rooms only.
98    pub skipped_custom: i64,
99    /// Rooms that had a price of their own and now have the batch's.
100    pub custom_overwritten: i64,
101    /// Rooms whose price was raised to the bottom of the band (Modern).
102    pub clamped_low: i64,
103    /// Rooms whose price was lowered to the top of the band (Modern).
104    pub clamped_high: i64,
105}
106
107impl Simulation {
108    /// Sets the price of every room of `kind` to `target`. With
109    /// `only_default_priced`, rooms the player priced themselves keep their
110    /// price. Returns `None`, changing nothing, for a kind that has no price,
111    /// for [`BatchTarget::NoRate`] in Modern, or for a price that is not a
112    /// finite number.
113    ///
114    /// ```
115    /// use verticopolis_engine::clock::GameMode;
116    /// use verticopolis_engine::facilities::Kind;
117    /// use verticopolis_engine::rent::BatchTarget;
118    /// use verticopolis_engine::sim::Simulation;
119    ///
120    /// let mut sim = Simulation::new_game(1, GameMode::Modern);
121    /// let none = sim.apply_rent_batch(Kind::Office, BatchTarget::Default, false).unwrap();
122    /// assert_eq!(none.matched, 0);
123    /// assert!(sim.apply_rent_batch(Kind::Lobby, BatchTarget::Default, false).is_none());
124    /// ```
125    pub fn apply_rent_batch(
126        &mut self,
127        kind: Kind,
128        target: BatchTarget,
129        only_default_priced: bool,
130    ) -> Option<BatchRentResult> {
131        let r = self.compute_batch(kind, target, only_default_priced, true);
132        if r.as_ref().is_some_and(|r| r.changed > 0) {
133            self.gameplay.push(GameplayEvent::PricingChanged { kind });
134        }
135        r
136    }
137
138    #[doc(hidden)]
139    /// `computeBatch(kind, target, opts, mutate)`: `None` for an unpriced
140    /// kind, a No Rate target off the ladder, or a non-finite price.
141    pub fn compute_batch(
142        &mut self,
143        kind: Kind,
144        target: BatchTarget,
145        only_default_priced: bool,
146        mutate: bool,
147    ) -> Option<BatchRentResult> {
148        let cfg = rent_config(kind)?;
149        let ladder = price_options(self.mode, kind)?;
150        if target == BatchTarget::NoRate && ladder.is_none() {
151            return None;
152        }
153        if let BatchTarget::Price(p) = target {
154            if !p.is_finite() {
155                return None;
156            }
157        }
158        let neutral = price_neutral(self.mode, kind)?;
159        let mut r = BatchRentResult::default();
160        // A priced kind is never structural, so the room list holds every
161        // unit of it, in list order: R units visited, not all of them.
162        let rooms = self.tower.room_index();
163        for &i in rooms.iter() {
164            let u = &mut self.tower.units[i];
165            if u.kind != kind {
166                continue;
167            }
168            r.matched += 1;
169            if u.kind == Kind::Condo && u.ever_occupied {
170                r.skipped_sold += 1;
171                continue;
172            }
173            // On the default means the effective price is the neutral anchor,
174            // read through the same fallback `rentOf` applies, and never off
175            // the market.
176            let neutral_priced =
177                !u.no_rate && is_neutral_price(u.rent.unwrap_or(cfg.default), neutral);
178            if only_default_priced && !neutral_priced {
179                r.skipped_custom += 1;
180                continue;
181            }
182            r.eligible += 1;
183            if target == BatchTarget::NoRate {
184                if !u.no_rate {
185                    r.changed += 1;
186                }
187                if mutate {
188                    if let Some(j) = self.tower.journal.as_mut() {
189                        j.unit_edit(i, u);
190                    }
191                    u.no_rate = true;
192                }
193                continue;
194            }
195            if !neutral_priced && !u.no_rate {
196                r.custom_overwritten += 1;
197            }
198            let before = rent_of(u.kind, u.rent, u.no_rate);
199            let value = match (target, ladder) {
200                (BatchTarget::Default, _) => neutral,
201                (BatchTarget::Price(p), Some(l)) => snap_to_ladder(&l, p),
202                (BatchTarget::Price(p), None) => {
203                    if p < cfg.min {
204                        r.clamped_low += 1;
205                    } else if p > cfg.max {
206                        r.clamped_high += 1;
207                    }
208                    p.min(cfg.max).max(cfg.min)
209                }
210                (BatchTarget::NoRate, _) => unreachable!("handled above"),
211            };
212            if before != value {
213                r.changed += 1;
214            }
215            if mutate {
216                if let Some(j) = self.tower.journal.as_mut() {
217                    j.unit_edit(i, u);
218                }
219                // `storeRent`: the default is stored as no override; an
220                // explicit reprice returns a No Rate unit to the market.
221                u.rent = if value == cfg.default {
222                    None
223                } else {
224                    Some(value)
225                };
226                u.no_rate = false;
227            }
228        }
229        Some(r)
230    }
231
232    #[doc(hidden)]
233    /// `demandFactor(u)`.
234    pub fn demand_factor(&self, u: &Unit) -> f64 {
235        let Some(neutral) = price_neutral(self.mode, u.kind) else {
236            return 1.0;
237        };
238        if u.no_rate {
239            return 0.0;
240        }
241        let ratio = rent_of(u.kind, u.rent, u.no_rate) / neutral;
242        (2.0 - ratio).min(1.6).max(0.15)
243    }
244
245    /// Sets room `id`'s price to `target` (snapped to a rung in Classic, kept
246    /// inside the band in Modern) and puts it back on the market. Returns the
247    /// price it set, or `None` for a room with no price, a sold condo or a
248    /// `target` that is not a finite number.
249    pub fn price_unit(&mut self, id: i64, target: f64) -> Option<f64> {
250        let mode = self.mode;
251        let u = self.tower.get_unit_mut(id)?;
252        let cfg = rent_config(u.kind)?;
253        let opts = price_options(mode, u.kind)?;
254        if !target.is_finite() {
255            return None;
256        }
257        if u.kind == Kind::Condo && u.ever_occupied {
258            return None;
259        }
260        let applied = match opts {
261            Some(l) => snap_to_ladder(&l, target),
262            None => target.min(cfg.max).max(cfg.min),
263        };
264        let before = (!u.no_rate).then(|| rent_of(u.kind, u.rent, u.no_rate));
265        u.rent = if applied == cfg.default {
266            None
267        } else {
268            Some(applied)
269        };
270        u.no_rate = false;
271        // A write that leaves the price where it was is not a change.
272        let changed = Some(rent_of(u.kind, u.rent, false)) != before;
273        let kind = u.kind;
274        if changed {
275            self.gameplay.push(GameplayEvent::PricingChanged { kind });
276        }
277        Some(applied)
278    }
279
280    /// Takes room `id` off the market, so it takes no new tenants. Classic
281    /// only, as in the original. Returns `false` for a room that cannot be,
282    /// such as a sold condo or any room in Modern.
283    pub fn set_no_rate(&mut self, id: i64) -> bool {
284        let mode = self.mode;
285        let Some(u) = self.tower.get_unit_mut(id) else {
286            return false;
287        };
288        if !matches!(price_options(mode, u.kind), Some(Some(_))) {
289            return false;
290        }
291        if u.kind == Kind::Condo && u.ever_occupied {
292            return false;
293        }
294        let was_off = u.no_rate;
295        u.no_rate = true;
296        let kind = u.kind;
297        if !was_off {
298            self.gameplay.push(GameplayEvent::PricingChanged { kind });
299        }
300        true
301    }
302
303    /// Moves room `id`'s price up (`dir` 1) or down (`dir` -1) by one rung in
304    /// Classic or one step in Modern. Returns the new price, or `None` as
305    /// [`price_unit`](Simulation::price_unit) does.
306    pub fn adjust_rent(&mut self, id: i64, dir: i64) -> Option<f64> {
307        let mode = self.mode;
308        let (kind, rent, no_rate) = {
309            let u = self.tower.get_unit(id)?;
310            (u.kind, u.rent, u.no_rate)
311        };
312        let cfg = rent_config(kind)?;
313        let opts = price_options(mode, kind)?;
314        let current = rent_of(kind, rent, no_rate);
315        match opts {
316            Some(l) => {
317                let idx = ladder_level(&l, current) as i64;
318                let next = l[(idx + dir).clamp(0, 3) as usize];
319                self.price_unit(id, next)
320            }
321            None => self.price_unit(id, current + dir as f64 * cfg.step),
322        }
323    }
324}