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