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