Skip to main content
Verticopolis engineGitHub

verticopolis_engine/load/
reader.rs

1//! Reading a save into a `Simulation`: the entry points and the per-unit reader.
2
3use super::*;
4use crate::economy::visitors::census_follows_the_day;
5use crate::tower::Extras;
6
7#[doc(hidden)]
8/// The sentence a save written by a newer build is refused with.
9pub const NEWER_SAVE_REFUSAL: &str =
10    "This save was made by a newer version of Verticopolis. Update the game to load it.";
11
12/// Builds a game from save JSON (from [`decode_vctower`] or
13/// [`Simulation::serialize`]). An older save is upgraded first, and damaged
14/// or edited fields are corrected the way the web game's loader corrects
15/// them. Refuses a save from a newer version of the engine, and one it cannot
16/// read, with a sentence a player can read.
17pub fn deserialize(raw: &Value) -> Result<Simulation, String> {
18    deserialize_owned(raw.clone())
19}
20
21/// [`deserialize`] for a save that [`import_tdt`](crate::import_tdt) made from
22/// a 1994 `.TDT` file. It also keeps the basement stops of the original's
23/// express elevators, which a normal load would drop.
24#[doc(hidden)]
25pub fn deserialize_tdt(raw: &Value) -> Result<Simulation, String> {
26    let mut sim = deserialize(raw)?;
27    if sim.mode != GameMode::Classic {
28        return Ok(sim);
29    }
30    let Some(source) = raw.get("transports").and_then(Value::as_array) else {
31        return Ok(sim);
32    };
33    let mut changed = false;
34    for shaft in &mut sim.tower.transports {
35        if shaft.kind != Kind::ElevatorExpress || shaft.bottom >= 0 {
36            continue;
37        }
38        let original = source.iter().find(|t| {
39            t.get("kind").and_then(Value::as_str) == Some("elevatorExpress")
40                && t.get("x").and_then(Value::as_i64) == Some(shaft.x)
41                && t.get("bottom").and_then(Value::as_i64) == Some(shaft.bottom)
42                && t.get("top").and_then(Value::as_i64) == Some(shaft.top)
43        });
44        let Some(authored) = original
45            .and_then(|t| t.get("skipFloors"))
46            .and_then(Value::as_array)
47        else {
48            continue;
49        };
50        let saved_skips: Vec<f64> = authored
51            .iter()
52            .filter_map(Value::as_f64)
53            .filter(|v| v.is_finite())
54            .collect();
55        let Some(skips) = shaft.skip_floors.as_mut() else {
56            continue;
57        };
58        // Mark only shafts actually read from the original TDT. The marker
59        // remains on ordinary .vctower saves and is checked during reload.
60        shaft.preserve_basement_stops = true;
61        let before = skips.len();
62        // Only remove basement skips added by general load normalization.
63        // Authored underground skips and all above-ground rules survive.
64        skips.retain(|floor| *floor > 0.0 || saved_skips.contains(floor));
65        changed |= skips.len() != before;
66    }
67    if changed {
68        sim.tower.revision += 1;
69    }
70    Ok(sim)
71}
72
73/// [`deserialize`] for save JSON the caller no longer needs: it upgrades the
74/// save in place and frees it as it goes, so a large tower loads in less
75/// memory.
76pub fn deserialize_owned(raw: Value) -> Result<Simulation, String> {
77    // `migrateSave(null)` throws reading `version`; any other top-level value
78    // loads as the TypeScript loads it (#858).
79    if raw.is_null() {
80        return Err("not a tower save (the save is null)".into());
81    }
82    // A save from a build newer than this one may carry fields this loader
83    // would drop, and the next save would write the loss back. Refuse it so
84    // the stored copy stays whole until the game is updated (#1603).
85    if let Some(version) = raw.get("version").and_then(Value::as_f64) {
86        if version.is_finite() && version > SAVE_VERSION as f64 {
87            return Err(NEWER_SAVE_REFUSAL.into());
88        }
89    }
90    // The founder test reads the save as written, and `assemble` reads
91    // nothing else from it, so two keys stand in for it.
92    let written = founder_keys(&raw);
93    let mut data = migrate_save_owned(raw);
94    let modern = data.get("mode").and_then(Value::as_str) == Some("modern");
95    let listed = match data.get_mut("units") {
96        Some(Value::Array(units)) => std::mem::take(units),
97        _ => Vec::new(),
98    };
99    // The entries `unit_from_value` keeps, counted before any unit is built,
100    // so a save past the cap is refused first and the rest fill vectors of
101    // their exact size.
102    let kept = listed
103        .iter()
104        .filter(|u| kind_of(u).is_some())
105        .take(UNIT_CAP + 1)
106        .count();
107    if kept > UNIT_CAP {
108        return Err(format!(
109            "This save lists more than {UNIT_CAP} units, more than the whole lot can hold, so it cannot be a real tower."
110        ));
111    }
112    let mut units = Vec::with_capacity(kept + unit_list_headroom(kept));
113    let mut ids = Vec::with_capacity(kept);
114    for entry in listed {
115        if let Some((unit, id)) = unit_from_value(&entry, modern) {
116            units.push(unit);
117            ids.push(id);
118        }
119    }
120    debug_assert_eq!(units.len(), kept, "unit_from_value skipped a counted entry");
121    Ok(assemble(&data, units, ids, &written))
122}
123
124/// The keys `detect_founder` reads off a save.
125pub(crate) fn founder_keys(raw: &Value) -> Value {
126    let mut keys = Map::new();
127    for key in ["founder", "appVersion"] {
128        if let Some(v) = raw.get(key) {
129            keys.insert(key.to_string(), v.clone());
130        }
131    }
132    Value::Object(keys)
133}
134
135/// One entry of a save's `units` array as `deserialize` reads it, with its
136/// saved id; `None` for an entry it skips (null, or a kind the catalog does
137/// not know), exactly when `kind_of` gives `None`, which the unit cap's
138/// count in `deserialize` relies on. It reads nothing outside the entry but
139/// the save's mode.
140pub(crate) fn unit_from_value(u: &Value, modern: bool) -> Option<(Unit, Option<i64>)> {
141    let kind = kind_of(u)?;
142    let cat = kind.facility().width;
143    // `drop_overlapping_units` indexes its tile grids by the floors and tiles
144    // these clamps allow, so a loosened clamp would trap there.
145    let floor =
146        (round(num(u.get("floor"), 1.0)) as i64).clamp(MIN_FLOOR, kind.highest_base_floor());
147    let x = (round(num(u.get("x"), 0.0)) as i64).clamp(0, LOT - 1);
148    let cap = if kind.is_structural() { cat } else { LOT - x };
149    let width = (round(num(u.get("width"), cat as f64)) as i64)
150        .min(cap)
151        .min(LOT - x)
152        .max(1);
153    let state = if kind.is_structural() {
154        UnitState::Empty
155    } else {
156        parse_state(u.get("state")).unwrap_or(UnitState::Empty)
157    };
158    let not_owned = matches!(
159        state,
160        UnitState::Empty | UnitState::Construction | UnitState::Gutted
161    );
162    let ever_occupied =
163        u.get("everOccupied") == Some(&Value::Bool(true)) && !(not_owned && !kind.is_hotel());
164    let sold_condo = kind == Kind::Condo && ever_occupied;
165    let keep_household = ever_occupied && kind.has_household();
166    let ladder_priced = !modern && classic_ladder(kind).is_some();
167    let mut rent: Option<f64> = match u.get("rent") {
168        None => None,
169        Some(r) if ladder_priced => Some(r.as_f64().unwrap_or(f64::NAN)),
170        Some(r) => Some(num(
171            Some(r),
172            rent_config(kind).map(|c| c.default).unwrap_or(0.0),
173        )),
174    };
175    if let Some(r) = rent {
176        if kind == Kind::Condo {
177            let ladder = if modern {
178                None
179            } else {
180                classic_ladder(Kind::Condo)
181            };
182            if sold_condo {
183                rent = Some(match ladder {
184                    Some(l) => js_max(l[0], js_min(l[3], r)),
185                    None => js_max(SOLD_CONDO_MIN_PRICE, js_min(SOLD_CONDO_MAX_PRICE, r)),
186                });
187            } else if ladder.is_none() {
188                let band = rent_config(Kind::Condo).unwrap();
189                rent = Some(js_max(band.min, js_min(band.max, r)));
190            }
191        }
192    }
193    let residents = if keep_household && modern {
194        match u.get("residents") {
195            None => None,
196            Some(r) => {
197                let v = if r.as_f64().is_some_and(|x| x.is_finite()) {
198                    r.as_f64().unwrap()
199                } else {
200                    3.0
201                };
202                Some((round(v) as i64).clamp(2, 5))
203            }
204        }
205    } else {
206        None
207    };
208    let film_policy: Option<&'static str> = match u.get("filmPolicy").and_then(Value::as_str) {
209        Some("feature") => Some("feature"),
210        Some("blockbuster") => Some("blockbuster"),
211        Some("auto") => Some("auto"),
212        Some("revival") => Some("revival"),
213        Some("off") => Some("off"),
214        _ => None,
215    };
216    let subtype: Option<&'static str> = match (
217        u.get("subtype").and_then(Value::as_str),
218        kind.subtype_list(),
219    ) {
220        (Some(s), Some(list)) => list.iter().copied().find(|c| *c == s),
221        _ => None,
222    };
223    let retail = kind.subtype_list().is_some();
224    let retail_field = |key: &str| -> Option<f64> {
225        if !retail {
226            return None;
227        }
228        let v = u.get(key)?;
229        Some(num(Some(v), 0.0).max(0.0))
230    };
231    let no_rate =
232        rent_config(kind).is_some() && !modern && u.get("noRate") == Some(&Value::Bool(true));
233    let unit = Unit {
234        id: 0,
235        kind,
236        floor,
237        x,
238        width,
239        state,
240        satisfaction: if kind.is_structural() {
241            1.0
242        } else {
243            num(u.get("satisfaction"), 1.0).min(1.0).max(0.0)
244        },
245        occupants: if kind.attendance_cap().is_some() || kind.is_structural() {
246            0
247        } else {
248            num(u.get("occupants"), 0.0).clamp(0.0, MAX_SAFE_INTEGER) as i64
249        },
250        customers_in: None,
251        hotel_customers_in: None,
252        out_for_meal: None,
253        residents,
254        visitors_yest: if census_follows_the_day(kind) {
255            u.get("visitorsYest")
256                .map(|v| num(Some(v), 0.0).clamp(0.0, f64::from(u8::MAX)).floor() as u8)
257        } else {
258            None
259        },
260        ever_occupied,
261        pending_income: num(u.get("pendingIncome"), 0.0),
262        rent,
263        no_rate,
264        label: u
265            .get("label")
266            .and_then(Value::as_str)
267            .map(|s| s.to_string())
268            .unwrap_or_else(|| kind.facility().name.to_string()),
269        subtype,
270        extras: Extras {
271            vacate_reason: vacate_reason(u.get("vacateReason")),
272            vacate_at: u.get("vacateAt").map(|v| num(Some(v), 0.0)),
273            film_policy,
274            film_days: film_days(kind, u.get("filmDays")),
275            film_classic: cinema_flag(kind, u.get("filmClassic")),
276            // Only Modern shows a blockbuster (#1950); Classic reads a new film.
277            film_blockbuster: modern && cinema_flag(kind, u.get("filmBlockbuster")),
278            patronage_today: retail_field("patronageToday"),
279            patronage_yest: retail_field("patronageYest"),
280            profit_today: retail_field("profitToday"),
281            profit_yest: retail_field("profitYest"),
282            complete_at: u.get("completeAt").and_then(Value::as_f64),
283            complete_at_raw: match u.get("completeAt") {
284                Some(v) if !v.is_number() => Some(v.clone()),
285                _ => None,
286            },
287            dirty_days: coerce_dirty_days(state, kind, u.get("dirtyDays")),
288        }
289        .boxed(),
290    };
291    Some((unit, sane_id(u.get("id"))))
292}