src/save.rs 17.3 K · 531 lines · raw · history

1 use bevy::{ecs::system::SystemState, prelude::*};
2 use serde::{Deserialize, Serialize};
3
4 use crate::{
5 actions::Action,
6 animals::{Animal, AnimalAssets, AnimalKind, Tame, spawn_animal},
7 building::{Blueprints, BuildKind},
8 colonist::{Colonist, ColonistAssets, NAMES, spawn_colonist},
9 designations::Designations,
10 economy::ColonyGoals,
11 family::{Affinity, CHILD_SCALE, Child, Family, Homes, Partner},
12 food::Hunger,
13 items::{Carrying, Item, ItemAssets, spawn_stack},
14 jobs::{Tool, WorkPriorities},
15 mood::{Age, LastMeal, Losses, Mood, Sick, Unhappy},
16 needs::Rest,
17 population::{ColonyLog, Starvation},
18 rng::SimRng,
19 sim::{SimPos, SimTick, sim_pos},
20 structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure},
21 world::{MapLayers, TilePos, WorldMap},
22 worldgen::{WorldGenerated, WorldSeed, generate},
23 zones::{Zones, ZonesSave},
24 };
25
26 pub struct SavePlugin;
27
28 impl Plugin for SavePlugin {
29 fn build(&self, app: &mut App) {
30 app.add_systems(Update, save_game)
31 .add_systems(PreUpdate, start_load)
32 .add_systems(PostUpdate, finish_load);
33 }
34 }
35
36 const SAVE_VERSION: u32 = 1;
37
38 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
39 pub struct ColonistSave {
40 name: String,
41 pos: Vec2,
42 tile: UVec2,
43 rest: f32,
44 hunger: f32,
45 mood: Option<f32>,
46 born: Option<i64>,
47 family: Option<u32>,
48 partner: Option<usize>,
49 child_born: Option<u64>,
50 carrying: Option<Item>,
51 priorities: WorkPriorities,
52 tool_wear: Option<u32>,
53 sick_until: Option<u64>,
54 last_meal: Option<(bool, u64)>,
55 unhappy: u32,
56 starvation: u32,
57 }
58
59 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
60 pub struct AnimalSave {
61 kind: AnimalKind,
62 pos: Vec2,
63 tile: UVec2,
64 tame: bool,
65 }
66
67 #[derive(Serialize, Deserialize, Debug)]
68 pub struct SaveGame {
69 version: u32,
70 seed: u32,
71 tick: u64,
72 rng: SimRng,
73 map: MapLayers,
74 zones: ZonesSave,
75 blueprints: Vec<(UVec2, BuildKind)>,
76 homes: Homes,
77 designations: Designations,
78 goals: ColonyGoals,
79 log: ColonyLog,
80 losses: Losses,
81 colonists: Vec<ColonistSave>,
82 items: Vec<(UVec2, Item)>,
83 structures: Vec<(UVec2, StructureKind, Option<u32>)>,
84 animals: Vec<AnimalSave>,
85 }
86
87 #[allow(clippy::type_complexity)]
88 pub fn snapshot(world: &mut World) -> SaveGame {
89 let mut colonist_query = world.query::<(
90 Entity,
91 &Colonist,
92 &SimPos,
93 &TilePos,
94 &Rest,
95 &Hunger,
96 &Carrying,
97 &WorkPriorities,
98 (
99 Option<&Mood>,
100 Option<&Age>,
101 Option<&Family>,
102 Option<&Partner>,
103 Option<&Child>,
104 Option<&Tool>,
105 Option<&Sick>,
106 Option<&LastMeal>,
107 Option<&Unhappy>,
108 Option<&Starvation>,
109 ),
110 )>();
111 let rows: Vec<_> = colonist_query.iter(world).collect();
112 let order: Vec<Entity> = rows.iter().map(|r| r.0).collect();
113 let colonists = rows
114 .iter()
115 .map(
116 |&(_, colonist, pos, tile, rest, hunger, carrying, priorities, extra)| {
117 let (mood, age, family, partner, child, tool, sick, meal, unhappy, starvation) =
118 extra;
119 ColonistSave {
120 name: colonist.name.clone(),
121 pos: pos.0,
122 tile: tile.0,
123 rest: rest.0,
124 hunger: hunger.0,
125 mood: mood.map(|m| m.0),
126 born: age.map(|a| a.born),
127 family: family.map(|f| f.0),
128 partner: partner.and_then(|p| order.iter().position(|&e| e == p.0)),
129 child_born: child.map(|c| c.born),
130 carrying: carrying.0,
131 priorities: priorities.clone(),
132 tool_wear: tool.map(|t| t.wear),
133 sick_until: sick.map(|s| s.until),
134 last_meal: meal.map(|m| (m.cooked, m.tick)),
135 unhappy: unhappy.map_or(0, |u| u.0),
136 starvation: starvation.map_or(0, |s| s.0),
137 }
138 },
139 )
140 .collect();
141 let items = world
142 .query::<(&Item, &TilePos)>()
143 .iter(world)
144 .map(|(item, pos)| (pos.0, *item))
145 .collect();
146 let structures = world
147 .query::<(&Structure, &TilePos, Option<&Fuel>)>()
148 .iter(world)
149 .map(|(s, pos, fuel)| (pos.0, s.kind, fuel.map(|f| f.0)))
150 .collect();
151 let animals = world
152 .query::<(&Animal, &SimPos, &TilePos, Has<Tame>)>()
153 .iter(world)
154 .map(|(animal, pos, tile, tame)| AnimalSave {
155 kind: animal.kind,
156 pos: pos.0,
157 tile: tile.0,
158 tame,
159 })
160 .collect();
161 let mut blueprints: Vec<(UVec2, BuildKind)> = world
162 .resource::<Blueprints>()
163 .0
164 .iter()
165 .map(|(t, k)| (*t, *k))
166 .collect();
167 blueprints.sort_by_key(|(t, _)| (t.y, t.x));
168 SaveGame {
169 version: SAVE_VERSION,
170 seed: world.resource::<WorldSeed>().0,
171 tick: world.resource::<SimTick>().0,
172 rng: world.resource::<SimRng>().clone(),
173 map: world.resource::<WorldMap>().layers(),
174 zones: world.resource::<Zones>().save(),
175 blueprints,
176 homes: world.resource::<Homes>().clone(),
177 designations: Designations(world.resource::<Designations>().0.clone()),
178 goals: world.resource::<ColonyGoals>().clone(),
179 log: ColonyLog(world.resource::<ColonyLog>().0.clone()),
180 losses: Losses(world.resource::<Losses>().0.clone()),
181 colonists,
182 items,
183 structures,
184 animals,
185 }
186 }
187
188 pub fn restore_map(save: &SaveGame, size: UVec2) -> Result<WorldMap, String> {
189 if save.version != SAVE_VERSION {
190 return Err(format!("unsupported save version {}", save.version));
191 }
192 let mut map = generate(size, save.seed);
193 map.restore(&save.map)?;
194 Ok(map)
195 }
196
197 #[derive(Resource)]
198 struct PendingLoad(SaveGame);
199
200 fn despawn_all<T: Component>(world: &mut World) {
201 let entities: Vec<Entity> = world
202 .query_filtered::<Entity, With<T>>()
203 .iter(world)
204 .collect();
205 for entity in entities {
206 world.despawn(entity);
207 }
208 }
209
210 type SpawnParams<'w, 's> = (
211 Commands<'w, 's>,
212 Res<'w, ColonistAssets>,
213 Res<'w, ItemAssets>,
214 Res<'w, StructureAssets>,
215 Res<'w, AnimalAssets>,
216 ResMut<'w, Assets<ColorMaterial>>,
217 );
218
219 pub fn apply(world: &mut World, save: SaveGame) {
220 despawn_all::<Colonist>(world);
221 despawn_all::<Animal>(world);
222 despawn_all::<Item>(world);
223 despawn_all::<Structure>(world);
224 world.insert_resource(SimTick(save.tick));
225 world.insert_resource(save.rng);
226 world.insert_resource(Zones::load(save.zones));
227 world.insert_resource(Blueprints(save.blueprints.into_iter().collect()));
228 world.insert_resource(save.homes);
229 world.insert_resource(save.designations);
230 world.insert_resource(save.goals);
231 world.insert_resource(save.log);
232 world.insert_resource(save.losses);
233 world.insert_resource(Affinity::default());
234 let mut state: SystemState<SpawnParams> = SystemState::new(world);
235 let (
236 mut commands,
237 colonist_assets,
238 item_assets,
239 structure_assets,
240 animal_assets,
241 mut materials,
242 ) = state.get_mut(world).expect("assets exist");
243 for (tile, item) in save.items {
244 spawn_stack(&mut commands, &item_assets, tile, item);
245 }
246 for (tile, kind, fuel) in save.structures {
247 let entity = spawn_structure(&mut commands, &structure_assets, tile, kind);
248 if let Some(fuel) = fuel {
249 commands.entity(entity).insert(Fuel(fuel));
250 }
251 }
252 for animal in save.animals {
253 let entity = spawn_animal(&mut commands, &animal_assets, animal.kind, animal.tile);
254 let mut entity = commands.entity(entity);
255 entity.insert(sim_pos(animal.pos));
256 if animal.tame {
257 entity.insert(Tame);
258 }
259 }
260 let entities: Vec<Entity> = save
261 .colonists
262 .iter()
263 .map(|c| {
264 let index = NAMES.iter().position(|n| *n == c.name).unwrap_or(0);
265 let entity = spawn_colonist(
266 &mut commands,
267 &colonist_assets,
268 &item_assets,
269 &mut materials,
270 index,
271 c.tile,
272 );
273 let mut e = commands.entity(entity);
274 e.insert((
275 Colonist {
276 name: c.name.clone(),
277 },
278 Name::new(c.name.clone()),
279 sim_pos(c.pos),
280 Rest(c.rest),
281 Hunger(c.hunger),
282 Carrying(c.carrying),
283 c.priorities.clone(),
284 Unhappy(c.unhappy),
285 Starvation(c.starvation),
286 ));
287 if let Some(mood) = c.mood {
288 e.insert(Mood(mood));
289 }
290 if let Some(born) = c.born {
291 e.insert(Age { born });
292 }
293 if let Some(family) = c.family {
294 e.insert(Family(family));
295 }
296 if let Some(born) = c.child_born {
297 e.insert((
298 Child { born },
299 Transform::from_translation(c.pos.extend(1.0))
300 .with_scale(Vec3::splat(CHILD_SCALE)),
301 ));
302 }
303 if let Some(wear) = c.tool_wear {
304 e.insert(Tool { wear });
305 }
306 if let Some(until) = c.sick_until {
307 e.insert(Sick { until });
308 }
309 if let Some((cooked, tick)) = c.last_meal {
310 e.insert(LastMeal { cooked, tick });
311 }
312 entity
313 })
314 .collect();
315 for (c, &entity) in save.colonists.iter().zip(&entities) {
316 if let Some(&partner) = c.partner.and_then(|i| entities.get(i)) {
317 commands.entity(entity).insert(Partner(partner));
318 }
319 }
320 state.apply(world);
321 }
322
323 const SAVE_KEY: &str = "simulator-save";
324
325 #[cfg(target_arch = "wasm32")]
326 fn storage() -> Result<web_sys::Storage, String> {
327 web_sys::window()
328 .and_then(|w| w.local_storage().ok().flatten())
329 .ok_or_else(|| "no local storage".to_string())
330 }
331
332 #[cfg(target_arch = "wasm32")]
333 fn write_save(data: &str) -> Result<(), String> {
334 storage()?
335 .set_item(SAVE_KEY, data)
336 .map_err(|_| "storage is full".to_string())
337 }
338
339 #[cfg(target_arch = "wasm32")]
340 fn read_save() -> Result<String, String> {
341 storage()?
342 .get_item(SAVE_KEY)
343 .ok()
344 .flatten()
345 .ok_or_else(|| "no saved game".to_string())
346 }
347
348 #[cfg(not(target_arch = "wasm32"))]
349 fn save_path() -> std::path::PathBuf {
350 std::path::PathBuf::from(format!("{SAVE_KEY}.json"))
351 }
352
353 #[cfg(not(target_arch = "wasm32"))]
354 fn write_save(data: &str) -> Result<(), String> {
355 std::fs::write(save_path(), data).map_err(|e| e.to_string())
356 }
357
358 #[cfg(not(target_arch = "wasm32"))]
359 fn read_save() -> Result<String, String> {
360 std::fs::read_to_string(save_path()).map_err(|_| "no saved game".to_string())
361 }
362
363 fn save_game(world: &mut World, mut reader: Local<Option<SystemState<MessageReader<Action>>>>) {
364 let state = reader.get_or_insert_with(|| SystemState::new(world));
365 let requested = state
366 .get_mut(world)
367 .is_ok_and(|mut actions| actions.read().any(|a| *a == Action::Save));
368 if !requested {
369 return;
370 }
371 let save = snapshot(world);
372 let result = serde_json::to_string(&save)
373 .map_err(|e| e.to_string())
374 .and_then(|data| write_save(&data));
375 let tick = *world.resource::<SimTick>();
376 let message = match result {
377 Ok(()) => "Game saved".to_string(),
378 Err(e) => format!("Save failed: {e}"),
379 };
380 world.resource_mut::<ColonyLog>().push(tick, message);
381 }
382
383 fn start_load(world: &mut World, mut reader: Local<Option<SystemState<MessageReader<Action>>>>) {
384 let state = reader.get_or_insert_with(|| SystemState::new(world));
385 let requested = state
386 .get_mut(world)
387 .is_ok_and(|mut actions| actions.read().any(|a| *a == Action::Load));
388 if !requested {
389 return;
390 }
391 let size = world.resource::<WorldMap>().size;
392 let loaded = read_save().and_then(|data| {
393 let save: SaveGame = serde_json::from_str(&data).map_err(|e| e.to_string())?;
394 let map = restore_map(&save, size)?;
395 Ok((save, map))
396 });
397 match loaded {
398 Ok((save, map)) => {
399 world.insert_resource(WorldSeed(save.seed));
400 world.insert_resource(map);
401 world.write_message(WorldGenerated);
402 world.insert_resource(PendingLoad(save));
403 }
404 Err(e) => {
405 let tick = *world.resource::<SimTick>();
406 world
407 .resource_mut::<ColonyLog>()
408 .push(tick, format!("Load failed: {e}"));
409 }
410 }
411 }
412
413 fn finish_load(world: &mut World) {
414 let Some(PendingLoad(save)) = world.remove_resource::<PendingLoad>() else {
415 return;
416 };
417 let tick = SimTick(save.tick);
418 apply(world, save);
419 world
420 .resource_mut::<ColonyLog>()
421 .push(tick, "Game loaded".to_string());
422 }
423
424 #[cfg(test)]
425 mod tests {
426 use super::*;
427 use crate::{items::ItemKind, world::Plant};
428
429 fn app() -> App {
430 let mut app = App::new();
431 app.insert_resource(WorldSeed(7))
432 .insert_resource(generate(UVec2::splat(64), 7))
433 .insert_resource(SimTick(12345))
434 .insert_resource(SimRng::new(3))
435 .init_resource::<Zones>()
436 .init_resource::<Blueprints>()
437 .init_resource::<Homes>()
438 .init_resource::<Designations>()
439 .init_resource::<ColonyGoals>()
440 .init_resource::<ColonyLog>()
441 .init_resource::<Losses>()
442 .init_resource::<ColonistAssets>()
443 .init_resource::<ItemAssets>()
444 .init_resource::<StructureAssets>()
445 .init_resource::<AnimalAssets>()
446 .init_resource::<Assets<ColorMaterial>>();
447 app
448 }
449
450 // Jobs, paths and reservations are not saved: loaded colonists start idle and choose again,
451 // which every job already handles after an interruption. Everything else round-trips.
452 #[test]
453 fn a_saved_colony_loads_back_the_same() {
454 let mut app = app();
455 let world = app.world_mut();
456 {
457 let mut map = world.resource_mut::<WorldMap>();
458 map.set_plant(UVec2::new(3, 3), Some(Plant::Crop));
459 map.set_growth(UVec2::new(3, 3), 0.42);
460 map.set_wall(UVec2::new(4, 4), true);
461 map.set_roof(UVec2::new(5, 5), true);
462 map.set_wear(UVec2::new(6, 6), 12.5);
463 }
464 world.resource_mut::<Blueprints>().0.insert(
465 UVec2::new(9, 9),
466 BuildKind::Structure(StructureKind::Statue),
467 );
468 let ada = world
469 .spawn((
470 Colonist { name: "Ada".into() },
471 sim_pos(Vec2::new(40.0, 40.0)),
472 TilePos(UVec2::new(2, 2)),
473 Hunger(0.4),
474 Family(1),
475 Tool { wear: 99 },
476 Carrying(Some(Item::new(ItemKind::Wood, 7))),
477 ))
478 .id();
479 let bram = world
480 .spawn((
481 Colonist {
482 name: "Bram".into(),
483 },
484 Family(1),
485 Partner(ada),
486 ))
487 .id();
488 world.entity_mut(ada).insert(Partner(bram));
489 world.spawn((Item::new(ItemKind::Meal, 3), TilePos(UVec2::new(1, 1))));
490 world.spawn((
491 Structure {
492 kind: StructureKind::Campfire,
493 },
494 TilePos(UVec2::new(8, 8)),
495 Fuel(500),
496 ));
497 let saved = snapshot(world);
498 let json = serde_json::to_string(&saved).unwrap();
499 assert!(json.len() < 100_000, "{}", json.len());
500
501 let mut fresh = self::app();
502 let world = fresh.world_mut();
503 world.spawn(Colonist {
504 name: "Stray".into(),
505 });
506 let loaded: SaveGame = serde_json::from_str(&json).unwrap();
507 let map = restore_map(&loaded, UVec2::splat(64)).unwrap();
508 world.insert_resource(map);
509 apply(world, loaded);
510
511 let map = world.resource::<WorldMap>();
512 assert_eq!(map.growth(IVec2::new(3, 3)), 0.42);
513 assert!(map.is_wall(IVec2::new(4, 4)) && map.is_roofed(IVec2::new(5, 5)));
514 assert_eq!(map.wear(IVec2::new(6, 6)), 12.5);
515 assert_eq!(map.worn_tiles(), vec![UVec2::new(6, 6)]);
516 assert_eq!(world.resource::<SimTick>().0, 12345);
517 assert_eq!(world.resource::<Blueprints>().0.len(), 1);
518 let mut colonists = world.query::<(Entity, &Colonist, Option<&Partner>, &Hunger)>();
519 let rows: Vec<_> = colonists.iter(world).collect();
520 assert_eq!(rows.len(), 2);
521 let find = |name: &str| rows.iter().find(|r| r.1.name == name).unwrap();
522 let (ada, bram) = (find("Ada"), find("Bram"));
523 assert_eq!(ada.2.map(|p| p.0), Some(bram.0));
524 assert_eq!(ada.3.0, 0.4);
525 assert_eq!(world.get::<Tool>(ada.0), Some(&Tool { wear: 99 }));
526 assert_eq!(world.get::<Carrying>(ada.0).unwrap().0.unwrap().count, 7);
527 let fuel: Vec<u32> = world.query::<&Fuel>().iter(world).map(|f| f.0).collect();
528 assert_eq!(fuel, vec![500]);
529 assert_eq!(world.query::<&Item>().iter(world).count(), 1);
530 }
531 }