Skip to main content
Verticopolis engineGitHub

verticopolis_engine/
catalog.rs

1//! The catalog: engine-authored data a frontend draws menus, prices and
2//! placement ghosts from, resolved for one mode so the frontend never applies
3//! simulation rules itself.
4//!
5//! [`catalog`] returns a [`Catalog`]: world geometry, economy constants, pooled
6//! caps and one [`FacilityEntry`] per facility kind. It serializes to JSON
7//! with camelCase keys, the same JSON the JavaScript package returns.
8//!
9//! ```
10//! use verticopolis_engine::catalog::{catalog, CATALOG_VERSION};
11//! use verticopolis_engine::clock::GameMode;
12//! use verticopolis_engine::facilities::Kind;
13//!
14//! let classic = catalog(GameMode::Classic);
15//! assert_eq!(classic.version, CATALOG_VERSION);
16//! let office = classic.facilities.iter().find(|f| f.key == Kind::Office.as_str()).unwrap();
17//! println!("{} costs ${} and opens at {} star", office.name, office.cost, office.min_star);
18//! ```
19//!
20//! The conformance suite pins each mode's catalog by hash, so a change to a
21//! price or a rule shows up as a deliberate change to the lock.
22
23use serde::Serialize;
24
25use crate::build::pays_for_floor;
26use crate::charges::{car_resale_refund, ADD_CAR_COST, TRANSPORT_FLOOR_COST};
27use crate::clock::{resolve_calendar, CalendarKind, GameMode};
28use crate::econ::{classic_ladder, rent_config};
29use crate::economy::films::{FilmOffer, FilmRebook};
30use crate::economy::{
31    catalog_spend_per_customer, commercial_daily_income, service_maintenance_monthly,
32    MAINTENANCE_PER_CAR_MONTHLY, NIGHTCLUB_DJ_MONTHLY, REAL_WORLD_MAINT_PERIOD_DAYS,
33};
34use crate::facilities::{
35    build_cap, ground_floor_structure_kind, no_basement, Kind, FACILITIES, LOBBY_INTERVAL,
36    LOT_WIDTH, MAX_FLOOR, MIN_FLOOR, POOLED_CAPS,
37};
38use crate::rules::MAX_HOUSEHOLD;
39use crate::services::GARBAGE_COLLECT_HOUR;
40use crate::sim::STARTING_MONEY;
41mod build;
42mod json;
43mod lock;
44
45pub use build::catalog;
46pub use json::{catalog_digest, catalog_json, catalog_value};
47#[doc(hidden)]
48pub use lock::{lock_view, LOCK_FOOTPRINT_EXEMPT};
49
50/// The version of the catalog's shape. It moves when a field is added,
51/// renamed or removed, so a frontend can refuse a shape it does not know.
52pub const CATALOG_VERSION: u32 = 10;
53
54/// Every price, size and build rule of one mode.
55#[derive(Clone, Debug, PartialEq, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct Catalog {
58    /// [`CATALOG_VERSION`].
59    pub version: u32,
60    /// The mode the values are resolved for: `classic` or `modern`.
61    pub mode: &'static str,
62    /// The lot and floor limits.
63    pub world: World,
64    /// Money figures a UI shows.
65    pub economy: Economy,
66    /// The mode's rules a UI shows.
67    pub rules: Rules,
68    /// The caps several kinds share, in the engine's order.
69    pub pools: Vec<PoolEntry>,
70    /// One entry per facility kind, in the engine's catalog order, the
71    /// kinds this mode cannot build included (`available: false`).
72    pub facilities: Vec<FacilityEntry>,
73}
74
75/// The tower geometry a placement needs.
76#[derive(Clone, Debug, PartialEq, Serialize)]
77#[serde(rename_all = "camelCase")]
78pub struct World {
79    /// The buildable lot width in tiles.
80    pub lot_width: i64,
81    /// The lowest floor (the deepest basement). Floors number without a gap:
82    /// 1 is the ground floor, 0 the first basement.
83    pub min_floor: i64,
84    /// The highest floor.
85    pub max_floor: i64,
86    /// The ground floor, where the main lobby sits.
87    pub ground_floor: i64,
88    /// What the floor tool lays on the ground floor: a lobby. A current game
89    /// holds no plain floor there, so each tile the floor tool lays pays for
90    /// its floor too (`paysForFloor`).
91    pub ground_floor_structure: &'static str,
92    /// Floors between lobbies: besides the ground floor, a sky lobby may sit
93    /// on every floor from 2 up that is a multiple of it (15, 30, ...).
94    pub lobby_interval: i64,
95}
96
97/// The mode's economy constants a UI shows, in dollars.
98#[derive(Clone, Debug, PartialEq, Serialize)]
99#[serde(rename_all = "camelCase")]
100pub struct Economy {
101    /// The treasury a new tower starts with.
102    pub starting_money: f64,
103    /// The price of one more elevator car on a shaft (`add_car` charges it).
104    pub add_car_cost: f64,
105    /// What `remove_car` refunds for one car.
106    pub car_resale_refund: f64,
107    /// The price of each floor an elevator spans past its first. The build
108    /// charges it, and `extend_transport` charges it for each floor an extend
109    /// adds. Stairs and escalators pay none.
110    pub transport_floor_cost: f64,
111    /// The calendars a tower of this mode can run, its default first. Every
112    /// `...Monthly` figure here and each facility's `upkeepMonthly` is quoted
113    /// per 30-day real-world month: the engine charges it once per
114    /// maintenance period, multiplied by that calendar's `maintenanceScale`.
115    pub calendars: Vec<CalendarEntry>,
116    /// Monthly upkeep per elevator car.
117    pub maintenance_per_car_monthly: f64,
118    /// Monthly operating overhead per leasable or trading unit (0 where the
119    /// mode charges none).
120    pub overhead_per_unit_monthly: f64,
121    /// Monthly tax on an unsold condo, as a fraction of its asking price (0
122    /// where the mode charges none). Each period the engine charges
123    /// `ceil(price * rate)`, multiplied by `maintenanceScale` and rounded.
124    pub condo_hold_tax_rate: f64,
125    /// A nightclub's monthly DJ booking.
126    pub nightclub_dj_monthly: f64,
127    /// Hour of the daily garbage-truck collection that empties recycling bins.
128    pub garbage_collect_hour: f64,
129    /// The paid exterminator's fees, or null where the mode has none.
130    pub exterminator: Option<ExterminatorFees>,
131    /// The films a theater can show, with their prices, in the order the
132    /// card lists them (#1932, #1950). No theater pays anything each period.
133    pub films: &'static [FilmOffer],
134    /// The Auto-rebook choices, in the order the card's button cycles
135    /// through them: each policy and the film a theater with it books again
136    /// once its film is spent (#1950). Empty where no theater rebooks.
137    pub film_rebooks: &'static [FilmRebook],
138    /// The policy a theater with none, or an old save's "auto", rebooks by,
139    /// or null where no theater rebooks.
140    pub film_rebook_default: Option<&'static str>,
141    /// Whether the player can switch automatic bridging off. When false,
142    /// bridging is always on. When true, a tower starts bridged unless it
143    /// was founded unbridged, and the live setting is per tower (the
144    /// engine's `autoBridge()`, the save's `autoBridge`).
145    pub auto_bridge_toggleable: bool,
146}
147
148/// Mode rules a frontend shows the player, each stated from the engine's own
149/// rule method, so a frontend reads them here instead of deciding them from
150/// the mode's name.
151#[derive(Clone, Debug, PartialEq, Serialize)]
152#[serde(rename_all = "camelCase")]
153pub struct Rules {
154    /// Whether a sold condo carries its own household of 2 to 5 that prices
155    /// the sale and counts its residents. When false every condo houses the
156    /// flat family and stores no household.
157    pub variant_households: bool,
158    /// Whether the build preview names the engine's refusal before the
159    /// click. When false the refusal shows only once a click is refused.
160    pub preview_names_refusal: bool,
161    /// Whether the elevator schedule's assisted tools answer: the intent
162    /// presets, auto-tune and the staffing advice. When false the schedule
163    /// is the manual grid only and each assisted query is refused.
164    pub schedule_assist: bool,
165    /// Whether the player may place new construction while the game is
166    /// paused. When false the frontend refuses a placement at speed 0 and
167    /// says to unpause; the engine itself has no paused state.
168    pub builds_while_paused: bool,
169}
170
171/// One calendar a tower can run and how it scales the quoted figures.
172#[derive(Clone, Debug, PartialEq, Serialize)]
173#[serde(rename_all = "camelCase")]
174pub struct CalendarEntry {
175    /// `canon` or `realWorld`, as a save names it.
176    pub key: &'static str,
177    /// Days per quarter, when quarterly rent lands.
178    pub quarter_days: i64,
179    /// Days between maintenance charges (and monthly rent collections).
180    pub maintenance_period_days: i64,
181    /// What a `...Monthly` figure and a monthly rent are multiplied by on
182    /// each maintenance period.
183    pub maintenance_scale: f64,
184    /// What a quarterly rent is multiplied by when the quarter lands.
185    pub quarterly_rent_scale: f64,
186}
187
188#[derive(Clone, Debug, PartialEq, Serialize)]
189#[serde(rename_all = "camelCase")]
190/// What the exterminator charges, where the mode has one.
191pub struct ExterminatorFees {
192    /// The flat fee of one call.
193    pub callout_fee: f64,
194    /// Added per infested room treated.
195    pub per_room_fee: f64,
196}
197
198/// A cap several kinds share.
199#[derive(Clone, Debug, PartialEq, Serialize)]
200#[serde(rename_all = "camelCase")]
201pub struct PoolEntry {
202    /// The name a [`FacilityEntry::pool`] refers to it by.
203    pub key: &'static str,
204    /// The label the engine's refusal names the pool by.
205    pub label: &'static str,
206    /// The most units of all its kinds together a tower may hold.
207    pub cap: i64,
208    /// The facility keys that share it.
209    pub kinds: Vec<&'static str>,
210}
211
212/// One facility kind, resolved for the catalog's mode.
213#[derive(Clone, Debug, PartialEq, Serialize)]
214#[serde(rename_all = "camelCase")]
215pub struct FacilityEntry {
216    /// The key saves and commands name the kind by.
217    pub key: &'static str,
218    /// The name a player sees, such as `Fast Food`.
219    pub name: &'static str,
220    /// `structure`, `transport`, `office`, `residential`, `hotel`, `food`,
221    /// `retail`, `entertainment`, `service` or `special`.
222    pub category: &'static str,
223    /// Width in tiles. A transport's width is its shaft's.
224    pub width: i64,
225    /// Height in floors (1 for an ordinary room, and 1 for a transport,
226    /// whose height is its span).
227    pub floors: i64,
228    /// The build price. An elevator adds `transportFloorCost` for each floor
229    /// past its first (top minus bottom). A room adds the floor's price for
230    /// every floor tile laid under it, and so does a kind that pays for its
231    /// floor (`paysForFloor`) placed where there is no floor yet. With
232    /// automatic bridging on, every gap tile bridged to the tower adds one
233    /// substrate tile: the lobby's price plus the floor's when placing a
234    /// lobby (on any floor), the floor's price for a floor tile or a room.
235    pub cost: f64,
236    /// Whether a tile of this kind placed where there is no floor yet also
237    /// pays the floor's price, for the floor it lays (the lobby). Over a
238    /// floor tile it pays `cost` alone.
239    pub pays_for_floor: bool,
240    /// The star rating that unlocks it.
241    pub min_star: i64,
242    /// The people it holds (tenants, residents or customers at a time).
243    pub population: i64,
244    /// The largest generated household size for this kind, or null.
245    pub max_household: Option<i64>,
246    /// The audience an attendance venue seats, or null.
247    pub attendance: Option<i64>,
248    /// Only Modern games build it.
249    pub modern_only: bool,
250    /// An elevator, stairs or an escalator.
251    pub transport: bool,
252    /// A staff-only transport (the service elevator).
253    pub staff_only: bool,
254    /// Built underground only.
255    pub basement: bool,
256    /// Never built underground.
257    pub no_basement: bool,
258    /// Whether this mode can build it at all (the star gate aside).
259    pub available: bool,
260    /// In-game minutes of construction (0 for structure and for transports,
261    /// which work as soon as they are placed).
262    pub build_minutes: f64,
263    /// What selling it refunds (a gutted unit refunds nothing).
264    pub resale_refund: f64,
265    /// The most a tower may hold of this kind alone, or null when uncapped.
266    pub build_cap: Option<i64>,
267    /// The [`PoolEntry::key`] of the cap it shares, or null.
268    pub pool: Option<&'static str>,
269    /// For a transport, the largest top floor minus bottom floor it may
270    /// have, else null. It serves `maxSpan + 1` floors: stairs and
271    /// escalators have 1 and link two floors.
272    pub max_span: Option<i64>,
273    /// For a transport, whether it is a fixed two-floor unit placed with one
274    /// tap and never resized (stairs, escalators).
275    pub fixed_span: bool,
276    /// For an elevator, the most cars a shaft takes, else null.
277    pub max_cars: Option<i64>,
278    /// For a transport, the passengers one car (or one flight) carries, else null.
279    pub car_capacity: Option<f64>,
280    /// The varieties a new unit draws from, in the engine's order, or null.
281    pub subtypes: Option<Vec<&'static str>>,
282    /// How the player prices it in this mode, or null when its price is not
283    /// player-set.
284    pub rent: Option<Rent>,
285    /// A trading venue's headline daily take in this mode, or null: the
286    /// ceiling the money loop pays a sold-out venue.
287    pub daily_income: Option<f64>,
288    /// The average ticket per customer the engine counts customers with,
289    /// for the venues that track them in this mode (those with subtypes and
290    /// a daily income), else null.
291    pub spend_per_customer: Option<f64>,
292    /// A service facility's monthly maintenance, or null.
293    pub upkeep_monthly: Option<f64>,
294    /// Whether it is open in each hour of the day, 0 to 23, for a kind that
295    /// keeps business hours (`hasBusinessHours`), else null (always open).
296    pub open_hours: Option<Vec<bool>>,
297    /// Whether a unit may carry its own household size (`residents`), which
298    /// then stands in for `population`; a condo's price scales with it
299    /// against the condo's `population` (`householdPrice`).
300    pub own_household: bool,
301    /// A foot-traffic trading kind: the one predicate the noise and
302    /// lobby-proximity rules key off (`isCommercial`).
303    pub commercial: bool,
304    /// A hotel room of any size.
305    pub hotel: bool,
306    /// A Modern lease amenity (fitness club, clinic): lease tenants that erode
307    /// on unserved placement and an over-market rent.
308    pub lease_amenity: bool,
309    /// A demand tenant whose unmet coverage drains satisfaction.
310    pub unmet_demand: bool,
311}
312
313/// How the player prices a kind: a ladder of fixed rungs (Classic) or a
314/// continuous band (Modern).
315#[derive(Clone, Debug, PartialEq, Serialize)]
316#[serde(
317    tag = "shape",
318    rename_all = "camelCase",
319    rename_all_fields = "camelCase"
320)]
321pub enum Rent {
322    /// Four rungs plus the No Rate off-market state.
323    Ladder {
324        /// When the price is collected: `quarterly`, `maintenancePeriod`, `sale` or `nightly`.
325        cadence: &'static str,
326        /// The price a new unit starts at (the Average rung).
327        default: f64,
328        /// The four rungs, Very Low to High.
329        rungs: Vec<Rung>,
330        /// Whether the mode offers No Rate (always true on a ladder).
331        no_rate: bool,
332    },
333    /// Any price from `min` to `max` in steps of `step`.
334    Band {
335        /// When the price is collected: `quarterly`, `maintenancePeriod`, `sale` or `nightly`.
336        cadence: &'static str,
337        /// The price a new unit starts at.
338        default: f64,
339        /// The lowest price.
340        min: f64,
341        /// The highest price.
342        max: f64,
343        /// How far one step up or down moves the price.
344        step: f64,
345        /// Whether the mode offers No Rate (always false on a band).
346        no_rate: bool,
347    },
348}
349
350/// One rung of a price ladder.
351#[derive(Clone, Debug, PartialEq, Serialize)]
352#[serde(rename_all = "camelCase")]
353pub struct Rung {
354    /// 0 (Very Low) to 3 (High).
355    pub level: i64,
356    /// `Very Low`, `Low`, `Average` or `High`.
357    pub label: &'static str,
358    /// The price.
359    pub value: f64,
360}
361
362pub(crate) const RUNG_LABELS: [&str; 4] = ["Very Low", "Low", "Average", "High"];
363
364/// The key each of [`POOLED_CAPS`] goes by, in its order.
365const POOL_KEYS: [&str; 2] = ["elevators", "walkways"];
366const _: () = assert!(
367    POOL_KEYS.len() == POOLED_CAPS.len(),
368    "every pooled cap needs a key"
369);
370
371/// The calendars a mode can run, its default first.
372fn calendars_for(mode: GameMode) -> Vec<CalendarEntry> {
373    let kinds: &[CalendarKind] = match mode {
374        GameMode::Classic => &[CalendarKind::Canon],
375        GameMode::Modern => &[CalendarKind::RealWorld, CalendarKind::Canon],
376    };
377    kinds
378        .iter()
379        .map(|&kind| {
380            let c = resolve_calendar(mode, kind);
381            CalendarEntry {
382                key: c.kind.as_str(),
383                quarter_days: c.quarter_days,
384                maintenance_period_days: c.maint_period_days,
385                maintenance_scale: c.maint_period_days as f64 / REAL_WORLD_MAINT_PERIOD_DAYS,
386                quarterly_rent_scale: mode.quarterly_rent_scale(c.quarter_days),
387            }
388        })
389        .collect()
390}
391
392/// When a priced kind's price is collected: `quarterly` (offices and the
393/// lease amenities, scaled by the calendar's `quarterlyRentScale`),
394/// `maintenancePeriod` (rental living, every maintenance period, scaled by
395/// `maintenanceScale`), `sale` (a condo, once) or `nightly` (hotel rooms).
396/// A priced kind with no collector here is a programming error, so the
397/// `one_entry_per_kind_in_catalog_order` test builds every mode's catalog
398/// and trips it before a build ships.
399fn rent_cadence(kind: Kind) -> &'static str {
400    match kind {
401        Kind::Office | Kind::FitnessClub | Kind::Clinic => "quarterly",
402        Kind::RentalStudio | Kind::RentalApartment => "maintenancePeriod",
403        Kind::Condo => "sale",
404        Kind::HotelSingle | Kind::HotelDouble | Kind::HotelSuite => "nightly",
405        _ => unreachable!("{kind:?} has a price but no rent cadence"),
406    }
407}
408
409/// `rules.priceOptions(kind)` as a [`Rent`]: the canon ladder in Classic for
410/// the kinds that have one, the tuned band in Modern for every priced kind.
411fn rent_for(mode: GameMode, kind: Kind) -> Option<Rent> {
412    match mode {
413        GameMode::Classic => classic_ladder(kind).map(|values| Rent::Ladder {
414            cadence: rent_cadence(kind),
415            default: values[crate::rent::AVERAGE_RUNG],
416            rungs: values
417                .iter()
418                .enumerate()
419                .map(|(i, &value)| Rung {
420                    level: i as i64,
421                    label: RUNG_LABELS[i],
422                    value,
423                })
424                .collect(),
425            no_rate: true,
426        }),
427        GameMode::Modern => rent_config(kind).map(|c| Rent::Band {
428            cadence: rent_cadence(kind),
429            default: c.default,
430            min: c.min,
431            max: c.max,
432            step: c.step,
433            no_rate: false,
434        }),
435    }
436}
437
438fn pool_key(kind: Kind) -> Option<&'static str> {
439    POOLED_CAPS
440        .iter()
441        .position(|p| p.kinds.contains(&kind))
442        .map(|i| POOL_KEYS[i])
443}
444
445fn facility_entry(mode: GameMode, kind: Kind) -> FacilityEntry {
446    let f = kind.facility();
447    let transport = kind.is_transport();
448    FacilityEntry {
449        key: f.key,
450        name: f.name,
451        category: f.category.as_str(),
452        width: f.width,
453        floors: kind.floors(),
454        cost: f.cost,
455        pays_for_floor: pays_for_floor(kind),
456        min_star: f.min_star,
457        population: f.population,
458        max_household: kind.has_household().then_some(MAX_HOUSEHOLD),
459        attendance: f.attendance,
460        modern_only: f.modern_only,
461        transport,
462        staff_only: f.staff_only,
463        basement: f.basement,
464        no_basement: no_basement(kind),
465        available: f.available_in(mode),
466        build_minutes: if transport { 0.0 } else { kind.build_minutes() },
467        resale_refund: kind.resale_refund(),
468        build_cap: build_cap(kind),
469        pool: pool_key(kind),
470        max_span: transport.then(|| kind.max_span()),
471        fixed_span: kind.is_fixed_span(),
472        max_cars: kind.is_elevator().then(|| kind.max_cars()),
473        car_capacity: transport.then(|| kind.car_capacity()),
474        subtypes: kind.subtype_list().map(|l| l.to_vec()),
475        rent: rent_for(mode, kind),
476        daily_income: commercial_daily_income(mode, kind),
477        spend_per_customer: catalog_spend_per_customer(mode, kind),
478        upkeep_monthly: service_maintenance_monthly(kind),
479        open_hours: kind
480            .has_business_hours()
481            .then(|| (0..24).map(|hour| kind.is_open_at(hour)).collect()),
482        own_household: kind.has_household(),
483        commercial: kind.is_commercial(),
484        hotel: kind.is_hotel(),
485        lease_amenity: kind.is_lease_amenity(),
486        unmet_demand: kind.is_unmet_demand_kind(),
487    }
488}
489
490#[cfg(test)]
491mod tests;
492
493#[cfg(test)]
494mod charge_tests;
495
496#[cfg(test)]
497mod rules_tests;