Skip to main content
Verticopolis engineGitHub

verticopolis_engine/economy/
films.rs

1//! A theater's film (#1932, #1950). Neither mode charges a theater each
2//! period. The player pays to show a film, the film ages until its draw is
3//! spent, and the mode's [`FilmRules`] say what each film costs, how long
4//! it draws, and what it earns.
5//!
6//! Classic follows SimTower: a new film or a cheaper classic, a run of six
7//! days at full draw and three fading, and a theater whose new film draws a
8//! full house fills the crowd's and the traffic model's blockbuster slots.
9//!
10//! Modern offers a revival, a feature or a blockbuster, each film draws for
11//! 28 days and fades for 14, its income follows its tier and its age, and a
12//! theater books its chosen film again the morning the old one is spent
13//! (`film_policy`, unless the player switched that off).
14
15use serde::Serialize;
16
17use super::visitors::{
18    FADING_FILM_DRAW, FILM_SPENT_DAYS, NEW_FILM_DRAW, NEW_FILM_FULL_DRAW_DAYS, SPENT_FILM_DRAW,
19};
20use crate::clock::GameMode;
21use crate::facilities::Kind;
22use crate::services::with_thousands;
23use crate::sim::{LogKind, Simulation};
24use crate::tower::{Extras, Unit};
25
26/// What showing a new film costs in SimTower.
27pub const NEW_FILM_PRICE: f64 = 300_000.0;
28/// What showing a classic film costs in SimTower.
29pub const CLASSIC_FILM_PRICE: f64 = 150_000.0;
30/// What a Modern revival, feature and blockbuster cost (#1950).
31pub const MODERN_REVIVAL_PRICE: f64 = 60_000.0;
32pub const MODERN_FEATURE_PRICE: f64 = 120_000.0;
33pub const MODERN_BLOCKBUSTER_PRICE: f64 = 300_000.0;
34/// What a theater showing a blockbuster earns, against a feature's 1.
35pub const BLOCKBUSTER_INCOME_MULT: f64 = 2.2;
36/// What a Modern revival earns at full draw, against a feature's 1.
37pub const REVIVAL_INCOME_MULT: f64 = 0.6;
38/// The share of its full-draw income a Modern film earns while fading, and
39/// once spent.
40pub const FADING_INCOME_SHARE: f64 = 0.6;
41pub const SPENT_INCOME_SHARE: f64 = 0.25;
42/// A Modern film's days at full draw, and the age at which it is spent.
43pub const MODERN_FULL_DRAW_DAYS: i64 = 28;
44pub const MODERN_SPENT_DAYS: i64 = 42;
45
46/// A film a theater can show. Classic calls `New` a new film and `Classic` a
47/// classic; Modern calls them a feature and a revival, and adds `Blockbuster`.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum FilmTier {
50    New,
51    Classic,
52    Blockbuster,
53}
54
55impl FilmTier {
56    /// Every tier, in the order the port numbers them.
57    pub const ALL: [FilmTier; 3] = [FilmTier::New, FilmTier::Classic, FilmTier::Blockbuster];
58
59    /// The tier's key in the catalog and the port.
60    pub const fn key(self) -> &'static str {
61        match self {
62            FilmTier::New => "new",
63            FilmTier::Classic => "classic",
64            FilmTier::Blockbuster => "blockbuster",
65        }
66    }
67
68    /// The film a theater's saved flags say it shows.
69    pub fn showing(extras: &Extras) -> FilmTier {
70        if extras.film_classic {
71            FilmTier::Classic
72        } else if extras.film_blockbuster {
73            FilmTier::Blockbuster
74        } else {
75            FilmTier::New
76        }
77    }
78}
79
80/// How far into its run a film is.
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub enum FilmStage {
83    Full,
84    Fading,
85    Spent,
86}
87
88/// How long a film draws in a mode.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub struct FilmRun {
91    /// The days a film keeps its full draw.
92    pub full_draw_days: i64,
93    /// The age in days from which a film is spent.
94    pub spent_days: i64,
95}
96
97impl FilmRun {
98    /// The stage of a film that has shown for `days_shown` days.
99    pub fn stage(self, days_shown: i64) -> FilmStage {
100        if days_shown < self.full_draw_days {
101            FilmStage::Full
102        } else if days_shown < self.spent_days {
103            FilmStage::Fading
104        } else {
105            FilmStage::Spent
106        }
107    }
108
109    /// The census draw of a `tier` film that has shown for `days_shown`
110    /// days: 2, 1 and 0 through its run, and never past a fading film's for
111    /// a classic or revival.
112    pub fn draw(self, days_shown: i64, tier: FilmTier) -> i64 {
113        let draw = match self.stage(days_shown) {
114            FilmStage::Full => NEW_FILM_DRAW,
115            FilmStage::Fading => FADING_FILM_DRAW,
116            FilmStage::Spent => SPENT_FILM_DRAW,
117        };
118        if tier == FilmTier::Classic {
119            draw.min(FADING_FILM_DRAW)
120        } else {
121            draw
122        }
123    }
124}
125
126/// A film a mode lets the player show.
127#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct FilmOffer {
130    #[serde(skip)]
131    pub tier: FilmTier,
132    /// The tier's key, as the catalog publishes it.
133    pub key: &'static str,
134    /// What the card calls the film.
135    pub label: &'static str,
136    /// What showing it costs.
137    pub price: f64,
138    /// How the log opens the film ("A new film opens...").
139    #[serde(skip)]
140    pub opening: &'static str,
141    /// What it earns at full draw, against a feature's 1, where income
142    /// follows the film's age.
143    #[serde(skip)]
144    pub income_mult: f64,
145}
146
147/// A choice on a Modern theater's Auto-rebook button: the policy the save
148/// stores, and the film it books again once the theater's film is spent, or
149/// none where the player switched rebooking off (#1950).
150#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
151#[serde(rename_all = "camelCase")]
152pub struct FilmRebook {
153    /// The policy, as the save and `setFilm` name it.
154    pub policy: &'static str,
155    /// The film's key in `films`, or null for off.
156    pub film: Option<&'static str>,
157    #[serde(skip)]
158    pub tier: Option<FilmTier>,
159}
160
161/// A mode's film rules.
162#[derive(Debug)]
163pub struct FilmRules {
164    /// The films the player can show, in the order the card lists them.
165    pub offers: &'static [FilmOffer],
166    pub run: FilmRun,
167    /// The tier whose film fills the blockbuster slots at full draw.
168    pub blockbuster_tier: FilmTier,
169    /// The Auto-rebook choices, in the order the button cycles through
170    /// them, where a theater books its policy's film again once its film is
171    /// spent (Modern); empty where it never does (Classic).
172    pub rebooks: &'static [FilmRebook],
173    /// The choice a theater with no policy, or an unlisted one (an old
174    /// save's "auto"), rebooks by.
175    pub rebook_default: Option<&'static str>,
176    /// Whether a theater's income follows its film's tier and age (Modern).
177    /// Classic earns the blockbuster multiplier in a blockbuster slot only.
178    pub income_follows_film: bool,
179}
180
181impl FilmRules {
182    /// The offer for `tier`, or `None` where the mode has no such film.
183    pub fn offer(&self, tier: FilmTier) -> Option<&'static FilmOffer> {
184        self.offers.iter().find(|o| o.tier == tier)
185    }
186
187    /// The film a theater with `policy` books again once its film is spent,
188    /// or `None` where it books none: rebooking is off, or the mode never
189    /// rebooks.
190    pub fn rebooked(&self, policy: Option<&str>) -> Option<FilmTier> {
191        let listed = |p: &str| self.rebooks.iter().find(|r| r.policy == p);
192        policy
193            .and_then(listed)
194            .or_else(|| self.rebook_default.and_then(listed))
195            .and_then(|r| r.tier)
196    }
197}
198
199pub static CLASSIC_FILMS: FilmRules = FilmRules {
200    offers: &[
201        FilmOffer {
202            tier: FilmTier::New,
203            key: "new",
204            label: "New film",
205            price: NEW_FILM_PRICE,
206            opening: "A new film",
207            income_mult: 1.0,
208        },
209        FilmOffer {
210            tier: FilmTier::Classic,
211            key: "classic",
212            label: "Classic film",
213            price: CLASSIC_FILM_PRICE,
214            opening: "A classic film",
215            income_mult: 1.0,
216        },
217    ],
218    run: FilmRun {
219        full_draw_days: NEW_FILM_FULL_DRAW_DAYS,
220        spent_days: FILM_SPENT_DAYS,
221    },
222    blockbuster_tier: FilmTier::New,
223    rebooks: &[],
224    rebook_default: None,
225    income_follows_film: false,
226};
227
228pub static MODERN_FILMS: FilmRules = FilmRules {
229    offers: &[
230        FilmOffer {
231            tier: FilmTier::Classic,
232            key: "classic",
233            label: "Revival",
234            price: MODERN_REVIVAL_PRICE,
235            opening: "A revival",
236            income_mult: REVIVAL_INCOME_MULT,
237        },
238        FilmOffer {
239            tier: FilmTier::New,
240            key: "new",
241            label: "Feature",
242            price: MODERN_FEATURE_PRICE,
243            opening: "A feature",
244            income_mult: 1.0,
245        },
246        FilmOffer {
247            tier: FilmTier::Blockbuster,
248            key: "blockbuster",
249            label: "Blockbuster",
250            price: MODERN_BLOCKBUSTER_PRICE,
251            opening: "A blockbuster",
252            income_mult: BLOCKBUSTER_INCOME_MULT,
253        },
254    ],
255    run: FilmRun {
256        full_draw_days: MODERN_FULL_DRAW_DAYS,
257        spent_days: MODERN_SPENT_DAYS,
258    },
259    blockbuster_tier: FilmTier::Blockbuster,
260    rebooks: &[
261        FilmRebook {
262            policy: "off",
263            film: None,
264            tier: None,
265        },
266        FilmRebook {
267            policy: "revival",
268            film: Some("classic"),
269            tier: Some(FilmTier::Classic),
270        },
271        FilmRebook {
272            policy: "feature",
273            film: Some("new"),
274            tier: Some(FilmTier::New),
275        },
276        FilmRebook {
277            policy: "blockbuster",
278            film: Some("blockbuster"),
279            tier: Some(FilmTier::Blockbuster),
280        },
281    ],
282    rebook_default: Some("feature"),
283    income_follows_film: true,
284};
285
286/// Why a theatre's film did not change.
287#[derive(Debug, Clone, Copy, PartialEq)]
288pub enum FilmRefusal {
289    /// The mode offers no such film (a blockbuster in Classic).
290    Unavailable,
291    /// The id names no theatre.
292    NotTheater,
293    /// The theatre is not open: under construction, burning or gutted.
294    Closed,
295    /// The film would cost more than the tower holds.
296    Funds {
297        /// The price of the film the player asked for.
298        cost: f64,
299    },
300}
301
302impl FilmRefusal {
303    /// The refusal's reason, as the port reports it.
304    pub fn reason(&self) -> &'static str {
305        match self {
306            FilmRefusal::Unavailable => "unavailable",
307            FilmRefusal::NotTheater => "notTheater",
308            FilmRefusal::Closed => "closed",
309            FilmRefusal::Funds { .. } => "funds",
310        }
311    }
312}
313
314/// The tier a theater shows and the days it has shown.
315fn showing(u: &Unit) -> (FilmTier, i64) {
316    let extras = u.extras();
317    (
318        FilmTier::showing(extras),
319        i64::from(extras.film_days.unwrap_or(0)),
320    )
321}
322
323/// Whether a theater's film fills a blockbuster slot tonight: the mode's
324/// blockbuster film at full draw.
325pub(crate) fn fills_blockbuster_slot(mode: GameMode, u: &Unit) -> bool {
326    let films = mode.films();
327    let (tier, days) = showing(u);
328    tier == films.blockbuster_tier && films.run.stage(days) == FilmStage::Full
329}
330
331/// The census draw of a theater's film tonight.
332pub(crate) fn audience_draw(mode: GameMode, u: &Unit) -> i64 {
333    let (tier, days) = showing(u);
334    mode.films().run.draw(days, tier)
335}
336
337/// What a theater's film multiplies its ticket income by. `in_slot` is
338/// whether it holds a blockbuster slot, which is all Classic reads.
339pub(crate) fn film_income_mult(mode: GameMode, u: &Unit, in_slot: bool) -> f64 {
340    let films = mode.films();
341    if !films.income_follows_film {
342        return if in_slot {
343            BLOCKBUSTER_INCOME_MULT
344        } else {
345            1.0
346        };
347    }
348    let (tier, days) = showing(u);
349    let peak = films.offer(tier).map_or(1.0, |o| o.income_mult);
350    let share = match films.run.stage(days) {
351        FilmStage::Full => 1.0,
352        FilmStage::Fading => FADING_INCOME_SHARE,
353        FilmStage::Spent => SPENT_INCOME_SHARE,
354    };
355    peak * share
356}
357
358impl Simulation {
359    /// Shows a `tier` film at cinema `id`: charges the price (the
360    /// [`catalog`](crate::catalog::catalog)'s `films`) and starts the film on
361    /// its first day. Returns the price paid, or why the film did not change
362    /// (a tier the mode does not offer is `Unavailable`).
363    pub fn change_film(&mut self, id: i64, tier: FilmTier) -> Result<f64, FilmRefusal> {
364        let mode = self.tower.mode;
365        let offer = mode.films().offer(tier).ok_or(FilmRefusal::Unavailable)?;
366        let u = self
367            .tower
368            .get_unit(id)
369            .filter(|u| u.kind == Kind::Cinema)
370            .ok_or(FilmRefusal::NotTheater)?;
371        if !u.is_operational() {
372            return Err(FilmRefusal::Closed);
373        }
374        let cost = offer.price;
375        if self.money < cost {
376            return Err(FilmRefusal::Funds { cost });
377        }
378        self.money -= cost;
379        self.record_money("entertainment", -cost);
380        let fills_slot = {
381            let u = self.tower.get_unit_mut(id).ok_or(FilmRefusal::NotTheater)?;
382            let mut extras = u.extras_mut();
383            extras.film_days = Some(0);
384            extras.film_classic = tier == FilmTier::Classic;
385            extras.film_blockbuster = tier == FilmTier::Blockbuster;
386            drop(extras);
387            fills_blockbuster_slot(mode, u)
388        };
389        let key = id as f64;
390        self.blockbusters.retain(|&b| b != key);
391        if fills_slot {
392            self.blockbusters.push(key);
393        }
394        self.emit(
395            &format!(
396                "๐ŸŽฌ {} opens at the movie theater: ${} charged.",
397                offer.opening,
398                with_thousands(cost)
399            ),
400            LogKind::Money,
401        );
402        Ok(cost)
403    }
404
405    /// Where the mode rebooks spent films (Modern), each open theater whose
406    /// film is spent books its policy's film again. Runs after the day's
407    /// rollover, so the spent film's last night is counted first. A theater
408    /// that cannot pay keeps its spent film, and the log says so on the
409    /// morning the film is spent.
410    pub fn rebook_spent_films(&mut self) {
411        let films = self.tower.mode.films();
412        if films.rebooks.is_empty() {
413            return;
414        }
415        let due: Vec<(i64, FilmTier, bool, i64)> = self
416            .tower
417            .units_of_kind(Kind::Cinema)
418            .filter(|u| u.is_operational())
419            .filter_map(|u| {
420                let (_, days) = showing(u);
421                let tier = films.rebooked(u.extras().film_policy)?;
422                (days >= films.run.spent_days).then_some((
423                    u.id,
424                    tier,
425                    days == films.run.spent_days,
426                    u.floor,
427                ))
428            })
429            .collect();
430        for (id, tier, first_spent_morning, floor) in due {
431            if let Err(FilmRefusal::Funds { cost }) = self.change_film(id, tier) {
432                if first_spent_morning {
433                    let label = films.offer(tier).map_or("film", |o| o.label);
434                    let msg = format!(
435                        "๐ŸŽฌ The movie theater on {} can't afford its next {} (${}).",
436                        self.floor_label(floor),
437                        label.to_lowercase(),
438                        with_thousands(cost)
439                    );
440                    self.emit(&msg, LogKind::Bad);
441                }
442            }
443        }
444    }
445}
446
447#[cfg(test)]
448#[path = "films_tests.rs"]
449mod films_tests;
450
451#[cfg(test)]
452#[path = "films_modern_tests.rs"]
453mod films_modern_tests;