src/save.rs 18 K · 560 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 despawn_all::<crate::raids::Raider>(world);
251 world.insert_resource(SimTick(save.tick));
252 world.insert_resource(save.rng);
253 world.insert_resource(Zones::load(save.zones));
254 world.insert_resource(Blueprints(save.blueprints.into_iter().collect()));
255 world.insert_resource(save.homes);
256 world.insert_resource(save.designations);
257 world.insert_resource(save.goals);
258 world.insert_resource(save.log);
259 world.insert_resource(save.losses);
260 world.insert_resource(Affinity::default());
261 let mut state: SystemState<SpawnParams> = SystemState::new(world);
262 let (
263 mut commands,
264 colonist_assets,
265 item_assets,
266 structure_assets,
267 animal_assets,
268 mut materials,
269 ) = state.get_mut(world).expect("assets exist");
270 for (tile, item) in save.items {
271 spawn_stack(&mut commands, &item_assets, tile, item);
272 }
273 for (tile, kind, fuel) in save.structures {
274 let entity = spawn_structure(&mut commands, &structure_assets, tile, kind);
275 if let Some(fuel) = fuel {
276 commands.entity(entity).insert(Fuel(fuel));
277 }
278 }
279 for animal in save.animals {
280 let entity = spawn_animal(&mut commands, &animal_assets, animal.kind, animal.tile);
281 let mut entity = commands.entity(entity);
282 entity.insert(sim_pos(animal.pos));
283 if animal.tame {
284 entity.insert(Tame);
285 }
286 }
287 let entities: Vec<Entity> = save
288 .colonists
289 .iter()
290 .map(|c| {
291 let index = NAMES.iter().position(|n| *n == c.name).unwrap_or(0);
292 let entity = spawn_colonist(
293 &mut commands,
294 &colonist_assets,
295 &item_assets,
296 &mut materials,
297 index,
298 c.tile,
299 );
300 let mut e = commands.entity(entity);
301 e.insert((
302 Colonist {
303 name: c.name.clone(),
304 },
305 Name::new(c.name.clone()),
306 sim_pos(c.pos),
307 Rest(c.rest),
308 Hunger(c.hunger),
309 Carrying(c.carrying),
310 c.priorities.clone(),
311 Unhappy(c.unhappy),
312 Starvation(c.starvation),
313 c.skills.clone(),
314 Health(c.health),
315 ));
316 if let Some(mood) = c.mood {
317 e.insert(Mood(mood));
318 }
319 if let Some(born) = c.born {
320 e.insert(Age { born });
321 }
322 if let Some(family) = c.family {
323 e.insert(Family(family));
324 }
325 if let Some(born) = c.child_born {
326 e.insert((
327 Child { born },
328 Transform::from_translation(c.pos.extend(1.0))
329 .with_scale(Vec3::splat(CHILD_SCALE)),
330 ));
331 }
332 if let Some(wear) = c.tool_wear {
333 e.insert(Tool { wear });
334 }
335 if let Some(until) = c.sick_until {
336 e.insert(Sick { until });
337 }
338 if let Some((cooked, tick)) = c.last_meal {
339 e.insert(LastMeal { cooked, tick });
340 }
341 entity
342 })
343 .collect();
344 for (c, &entity) in save.colonists.iter().zip(&entities) {
345 if let Some(&partner) = c.partner.and_then(|i| entities.get(i)) {
346 commands.entity(entity).insert(Partner(partner));
347 }
348 }
349 state.apply(world);
350 }
351
352 const SAVE_KEY: &str = "simulator-save";
353
354 #[cfg(target_arch = "wasm32")]
355 fn storage() -> Result<web_sys::Storage, String> {
356 web_sys::window()
357 .and_then(|w| w.local_storage().ok().flatten())
358 .ok_or_else(|| "no local storage".to_string())
359 }
360
361 #[cfg(target_arch = "wasm32")]
362 fn write_save(data: &str) -> Result<(), String> {
363 storage()?
364 .set_item(SAVE_KEY, data)
365 .map_err(|_| "storage is full".to_string())
366 }
367
368 #[cfg(target_arch = "wasm32")]
369 fn read_save() -> Result<String, String> {
370 storage()?
371 .get_item(SAVE_KEY)
372 .ok()
373 .flatten()
374 .ok_or_else(|| "no saved game".to_string())
375 }
376
377 #[cfg(not(target_arch = "wasm32"))]
378 fn save_path() -> std::path::PathBuf {
379 std::path::PathBuf::from(format!("{SAVE_KEY}.json"))
380 }
381
382 #[cfg(not(target_arch = "wasm32"))]
383 fn write_save(data: &str) -> Result<(), String> {
384 std::fs::write(save_path(), data).map_err(|e| e.to_string())
385 }
386
387 #[cfg(not(target_arch = "wasm32"))]
388 fn read_save() -> Result<String, String> {
389 std::fs::read_to_string(save_path()).map_err(|_| "no saved game".to_string())
390 }
391
392 fn save_game(world: &mut World, mut reader: Local<Option<SystemState<MessageReader<Action>>>>) {
393 let state = reader.get_or_insert_with(|| SystemState::new(world));
394 let requested = state
395 .get_mut(world)
396 .is_ok_and(|mut actions| actions.read().any(|a| *a == Action::Save));
397 if !requested {
398 return;
399 }
400 let save = snapshot(world);
401 let result = serde_json::to_string(&save)
402 .map_err(|e| e.to_string())
403 .and_then(|data| write_save(&data));
404 let tick = *world.resource::<SimTick>();
405 let message = match result {
406 Ok(()) => "Game saved".to_string(),
407 Err(e) => format!("Save failed: {e}"),
408 };
409 world.resource_mut::<ColonyLog>().push(tick, message);
410 }
411
412 fn start_load(world: &mut World, mut reader: Local<Option<SystemState<MessageReader<Action>>>>) {
413 let state = reader.get_or_insert_with(|| SystemState::new(world));
414 let requested = state
415 .get_mut(world)
416 .is_ok_and(|mut actions| actions.read().any(|a| *a == Action::Load));
417 if !requested {
418 return;
419 }
420 let size = world.resource::<WorldMap>().size;
421 let loaded = read_save().and_then(|data| {
422 let save: SaveGame = serde_json::from_str(&data).map_err(|e| e.to_string())?;
423 let map = restore_map(&save, size)?;
424 Ok((save, map))
425 });
426 match loaded {
427 Ok((save, map)) => {
428 world.insert_resource(WorldSeed(save.seed));
429 world.insert_resource(map);
430 world.write_message(WorldGenerated);
431 world.insert_resource(PendingLoad(save));
432 }
433 Err(e) => {
434 let tick = *world.resource::<SimTick>();
435 world
436 .resource_mut::<ColonyLog>()
437 .push(tick, format!("Load failed: {e}"));
438 }
439 }
440 }
441
442 fn finish_load(world: &mut World) {
443 let Some(PendingLoad(save)) = world.remove_resource::<PendingLoad>() else {
444 return;
445 };
446 let tick = SimTick(save.tick);
447 apply(world, save);
448 world
449 .resource_mut::<ColonyLog>()
450 .push(tick, "Game loaded".to_string());
451 }
452
453 #[cfg(test)]
454 mod tests {
455 use super::*;
456 use crate::{items::ItemKind, world::Plant};
457
458 fn app() -> App {
459 let mut app = App::new();
460 app.insert_resource(WorldSeed(7))
461 .insert_resource(generate(UVec2::splat(64), 7))
462 .insert_resource(SimTick(12345))
463 .insert_resource(SimRng::new(3))
464 .init_resource::<Zones>()
465 .init_resource::<Blueprints>()
466 .init_resource::<Homes>()
467 .init_resource::<Designations>()
468 .init_resource::<ColonyGoals>()
469 .init_resource::<ColonyLog>()
470 .init_resource::<Losses>()
471 .init_resource::<ColonistAssets>()
472 .init_resource::<ItemAssets>()
473 .init_resource::<StructureAssets>()
474 .init_resource::<AnimalAssets>()
475 .init_resource::<Assets<ColorMaterial>>();
476 app
477 }
478
479 // Jobs, paths and reservations are not saved: loaded colonists start idle and choose again,
480 // which every job already handles after an interruption. Everything else round-trips.
481 #[test]
482 fn a_saved_colony_loads_back_the_same() {
483 let mut app = app();
484 let world = app.world_mut();
485 {
486 let mut map = world.resource_mut::<WorldMap>();
487 map.set_plant(UVec2::new(3, 3), Some(Plant::Crop));
488 map.set_growth(UVec2::new(3, 3), 0.42);
489 map.set_wall(UVec2::new(4, 4), true);
490 map.set_roof(UVec2::new(5, 5), true);
491 map.set_wear(UVec2::new(6, 6), 12.5);
492 }
493 world.resource_mut::<Blueprints>().0.insert(
494 UVec2::new(9, 9),
495 BuildKind::Structure(StructureKind::Statue),
496 );
497 let ada = world
498 .spawn((
499 Colonist { name: "Ada".into() },
500 sim_pos(Vec2::new(40.0, 40.0)),
501 TilePos(UVec2::new(2, 2)),
502 Hunger(0.4),
503 Family(1),
504 Tool { wear: 99 },
505 Carrying(Some(Item::new(ItemKind::Wood, 7))),
506 ))
507 .id();
508 let bram = world
509 .spawn((
510 Colonist {
511 name: "Bram".into(),
512 },
513 Family(1),
514 Partner(ada),
515 ))
516 .id();
517 world.entity_mut(ada).insert(Partner(bram));
518 world.spawn((Item::new(ItemKind::Meal, 3), TilePos(UVec2::new(1, 1))));
519 world.spawn((
520 Structure {
521 kind: StructureKind::Campfire,
522 },
523 TilePos(UVec2::new(8, 8)),
524 Fuel(500),
525 ));
526 let saved = snapshot(world);
527 let json = serde_json::to_string(&saved).unwrap();
528 assert!(json.len() < 100_000, "{}", json.len());
529
530 let mut fresh = self::app();
531 let world = fresh.world_mut();
532 world.spawn(Colonist {
533 name: "Stray".into(),
534 });
535 let loaded: SaveGame = serde_json::from_str(&json).unwrap();
536 let map = restore_map(&loaded, UVec2::splat(64)).unwrap();
537 world.insert_resource(map);
538 apply(world, loaded);
539
540 let map = world.resource::<WorldMap>();
541 assert_eq!(map.growth(IVec2::new(3, 3)), 0.42);
542 assert!(map.is_wall(IVec2::new(4, 4)) && map.is_roofed(IVec2::new(5, 5)));
543 assert_eq!(map.wear(IVec2::new(6, 6)), 12.5);
544 assert_eq!(map.worn_tiles(), vec![UVec2::new(6, 6)]);
545 assert_eq!(world.resource::<SimTick>().0, 12345);
546 assert_eq!(world.resource::<Blueprints>().0.len(), 1);
547 let mut colonists = world.query::<(Entity, &Colonist, Option<&Partner>, &Hunger)>();
548 let rows: Vec<_> = colonists.iter(world).collect();
549 assert_eq!(rows.len(), 2);
550 let find = |name: &str| rows.iter().find(|r| r.1.name == name).unwrap();
551 let (ada, bram) = (find("Ada"), find("Bram"));
552 assert_eq!(ada.2.map(|p| p.0), Some(bram.0));
553 assert_eq!(ada.3.0, 0.4);
554 assert_eq!(world.get::<Tool>(ada.0), Some(&Tool { wear: 99 }));
555 assert_eq!(world.get::<Carrying>(ada.0).unwrap().0.unwrap().count, 7);
556 let fuel: Vec<u32> = world.query::<&Fuel>().iter(world).map(|f| f.0).collect();
557 assert_eq!(fuel, vec![500]);
558 assert_eq!(world.query::<&Item>().iter(world).count(), 1);
559 }
560 }