Add saving and loading

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 06:57 UTC
commit
4ab42bfca2b359c8c435c4676e3e2f5ef133eb90
parent
e31b807e1c
tree
browse at this commit

21 files changed +729 -24

CLAUDE.md +1 -0

@@ -48,6 +48,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
48 48 - `src/panel.rs` top-right colonist panel (also: stockpile section with accept toggles + priority cycle when a stockpile tile is selected; collapsible Stock targets with -/+/auto per Stock): roster of `ColonistButton`s (rebuilt when names/child/mood face change; click selects) and, for a selected adult, `WorkToggle` buttons per `WorkType::{Food, Build, Gather, Haul, Tame}` toggling the colonist's `WorkPriorities` (required on Colonist; red = disabled). choose_jobs' autonomous candidates are a `(WorkType, finder)` list filtered by `allows`; orders ignore priorities.
49 49 - `src/climate.rs` seasons/temperature. Year = YEAR_DAYS 12 (4 seasons × SEASON_DAYS 3, starts Spring; independent of ageing's day=year). `outdoor_temperature(tick)` = seasonal cosine (MEAN 12, swing 14, coldest at COLDEST_YEAR_FRACTION) + day/night swing (coldest 04:00); `daily_mean(day)`, `growing_day` (mean ≥ 8), `sowing_season` (next SOW_AHEAD_DAYS growing; gates find_sowing), `cold_days_ahead` (non-growing days in the next FORESIGHT_DAYS 6 → `ColonyGoals::set_cold_days`, food/wood buffer days = max(normal, cold days)). `growth_factor(temp)` scales plant growth; Crop with growth < 1 dies below FROST_TEMP. `spoil_factor` slows spoilage in cold. `tile_temperature(map, outdoor, tile, fires)`: roof pulls toward 16 by ROOF_INSULATION 0.7, fires add `StructureKind::heat()` with light-radius falloff, blocked by walls. `Warmth(f32)` on colonists (required), `update_warmth` every 60 ticks; `cold()` < COLD_TEMP 5 → hunger ×1.5, rest recovery ×0.5, mood −0.15. choose_jobs: cold colonists MoveTo `find_warm_spot` (tiles ≥ WARM_TEMP near fires) before work; `chilly_soon` triggers find_lighting's campfire (torches only when dark). `snow_cover(tick)` → `LightMap::with_snow` (8 steps, triggers recompute) → tilemap `Layer::Snow` (z 0.2, white alpha overlay on unroofed land). Info panel shows season/day/temp (font has no ° glyph).
50 50 - Crafting (jobs): `Task::Craft{recipe: Recipe::{Meal, Tools}, pickup, station, progress}` (was Cook): recipe gives station kind (Campfire/Workbench), accepted input, input count, work, output; `find_crafting`, `input_source`. Tools: `WorkType::Craft` candidate when Tools wanted and TOOL_IRON iron available → craft at a workbench, else `find_workbench` (one, near home, autonomous Structure build like campfire/bed). `Task::Equip{pickup}` (before autonomous candidates) for adults without `Tool{wear}`; equip records Tools use (learned demand); `tool_rate` ×1.5 work; wear per working tick, removed at TOOL_LIFE_TICKS (2 days). Iron has min goal 6. `StructureKind::Statue` (5 gold): plan_homes adds one blueprint at `Home::statue_tile` once a home is built → gold demand via planned_needs; mood +0.1 (`beauty`) when the family home has one.
51 +- `src/save.rs` save/load. Action::Save/Load (F5/F9). `snapshot(world) -> SaveGame` (serde_json; SAVE_VERSION): seed, tick, rng, `WorldMap::layers()` (`MapLayers`: RLE terrain/plants/walls/roofs as u8 + sparse growth/wear; heights regenerated by `generate(size, seed)` in `restore_map`), `Zones::save` (`ZonesSave`, UVec2-keyed maps become Vecs — serde_json needs string keys), blueprints Vec, Homes, Designations, ColonyGoals, log, losses, colonists (`ColonistSave`, partner as index), items, structures (+fuel), animals. Not saved: jobs, paths, reservations, Affinity, idle timers (colonists restart idle). Storage: web localStorage key `simulator-save` (~150 KB), native `simulator-save.json` in cwd. Load: `start_load` (PreUpdate, exclusive) parses, inserts WorldSeed + restored map, writes WorldGenerated (all reset systems run in Update), stores `PendingLoad`; `finish_load` (PostUpdate) despawns colonists/animals/items/structures and `apply`s the save via SystemState spawn helpers (`spawn_colonist`, `spawn_stack`, `spawn_structure` → Entity, `spawn_animal`). New persistent state = add to SaveGame + snapshot + apply (+ serde derives; bevy `serialize` feature for glam).
51 52 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
52 53
53 54 ## Performance notes

Cargo.lock +24 -0

@@ -24,6 +24,8 @@ version = "0.24.1"
24 24 source = "registry+https://github.com/rust-lang/crates.io-index"
25 25 checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
26 26 dependencies = [
27 + "enumn",
28 + "serde",
27 29 "uuid",
28 30 ]
29 31
@@ -453,6 +455,7 @@ dependencies = [
453 455 "bevy_derive",
454 456 "bevy_ecs",
455 457 "bevy_reflect",
458 + "serde",
456 459 ]
457 460
458 461 [[package]]
@@ -777,6 +780,7 @@ dependencies = [
777 780 "bevy_ui_widgets",
778 781 "bevy_window",
779 782 "bevy_world_serialization",
783 + "serde",
780 784 "tracing",
781 785 ]
782 786
@@ -1085,6 +1089,7 @@ dependencies = [
1085 1089 "bevy_reflect",
1086 1090 "derive_more",
1087 1091 "log",
1092 + "serde",
1088 1093 "smol_str",
1089 1094 "thiserror 2.0.21",
1090 1095 ]
@@ -1821,6 +1826,7 @@ dependencies = [
1821 1826 "bevy_window",
1822 1827 "derive_more",
1823 1828 "parley",
1829 + "serde",
1824 1830 "smallvec",
1825 1831 "swash",
1826 1832 "taffy",
@@ -1902,6 +1908,7 @@ dependencies = [
1902 1908 "bevy_platform",
1903 1909 "disqualified",
1904 1910 "indexmap",
1911 + "serde",
1905 1912 "thread_local",
1906 1913 ]
1907 1914
@@ -2558,6 +2565,17 @@ dependencies = [
2558 2565 ]
2559 2566
2560 2567 [[package]]
2568 +name = "enumn"
2569 +version = "0.1.14"
2570 +source = "registry+https://github.com/rust-lang/crates.io-index"
2571 +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38"
2572 +dependencies = [
2573 + "proc-macro2",
2574 + "quote",
2575 + "syn 2.0.119",
2576 +]
2577 +
2578 +[[package]]
2561 2579 name = "equivalent"
2562 2580 version = "1.0.3"
2563 2581 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4912,6 +4930,9 @@ name = "simulator"
4912 4930 version = "0.1.0"
4913 4931 dependencies = [
4914 4932 "bevy",
4933 + "serde",
4934 + "serde_json",
4935 + "web-sys",
4915 4936 ]
4916 4937
4917 4938 [[package]]
@@ -4944,6 +4965,9 @@ name = "smallvec"
4944 4965 version = "1.16.4"
4945 4966 source = "registry+https://github.com/rust-lang/crates.io-index"
4946 4967 checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
4968 +dependencies = [
4969 + "serde",
4970 +]
4947 4971
4948 4972 [[package]]
4949 4973 name = "smithay-client-toolkit"

Cargo.toml +6 -1

@@ -4,7 +4,12 @@ version = "0.1.0"
4 4 edition = "2024"
5 5
6 6 [dependencies]
7 -bevy = { version = "0.20.0" }
7 +bevy = { version = "0.20.0", features = ["serialize"] }
8 +serde = { version = "1", features = ["derive"] }
9 +serde_json = "1"
10 +
11 +[target.'cfg(target_arch = "wasm32")'.dependencies]
12 +web-sys = { version = "0.3", features = ["Window", "Storage"] }
8 13
9 14 # Enable a small amount of optimization in the dev profile.
10 15 [profile.dev]

src/actions.rs +7 -1

@@ -24,9 +24,11 @@ pub enum Action {
24 24 ToggleUi,
25 25 ZoomIn,
26 26 ZoomOut,
27 + Save,
28 + Load,
27 29 }
28 30
29 -pub const KEYBINDS: [(KeyCode, Action); 14] = [
31 +pub const KEYBINDS: [(KeyCode, Action); 16] = [
30 32 (KeyCode::Space, Action::TogglePause),
31 33 (KeyCode::Digit1, Action::SetSpeed(SimSpeed::Normal)),
32 34 (KeyCode::Digit2, Action::SetSpeed(SimSpeed::Fast)),
@@ -41,6 +43,8 @@ pub const KEYBINDS: [(KeyCode, Action); 14] = [
41 43 (KeyCode::KeyH, Action::ToggleUi),
42 44 (KeyCode::Equal, Action::ZoomIn),
43 45 (KeyCode::Minus, Action::ZoomOut),
46 + (KeyCode::F5, Action::Save),
47 + (KeyCode::F9, Action::Load),
44 48 ];
45 49
46 50 impl Action {
@@ -60,6 +64,8 @@ impl Action {
60 64 Action::ToggleUi => "Hide",
61 65 Action::ZoomIn => "+",
62 66 Action::ZoomOut => "-",
67 + Action::Save => "Save",
68 + Action::Load => "Load",
63 69 }
64 70 }
65 71

src/animals.rs +2 -1

@@ -1,3 +1,4 @@
1 +use serde::{Deserialize, Serialize};
1 2 use std::{ops::Range, time::Duration};
2 3
3 4 use bevy::prelude::*;
@@ -41,7 +42,7 @@ const MIN_DISTANCE_FROM_COLONY: f32 = 25.0;
41 42 const MIGRATION_INTERVAL_TICKS: u64 = 2 * TICKS_PER_HOUR;
42 43 const PLACEMENT_ATTEMPTS: usize = 200;
43 44
44 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
45 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
45 46 pub enum AnimalKind {
46 47 Deer,
47 48 Rabbit,

src/building.rs +2 -1

@@ -1,3 +1,4 @@
1 +use serde::{Deserialize, Serialize};
1 2 use std::collections::{HashMap, HashSet, VecDeque};
2 3
3 4 use bevy::prelude::*;
@@ -49,7 +50,7 @@ const PAVE_BLUEPRINT_COLOR: Color = Color::srgba(0.85, 0.85, 0.85, 0.5);
49 50 const DECONSTRUCT_COLOR: Color = Color::srgba(1.0, 0.3, 0.25, 0.9);
50 51 const ROOF_BLUEPRINT_COLOR: Color = Color::srgba(0.8, 0.6, 1.0, 0.35);
51 52
52 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
53 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
53 54 pub enum BuildKind {
54 55 Structure(StructureKind),
55 56 Roof,

src/designations.rs +2 -1

@@ -1,3 +1,4 @@
1 +use serde::{Deserialize, Serialize};
1 2 use std::collections::HashSet;
2 3
3 4 use bevy::prelude::*;
@@ -39,7 +40,7 @@ impl Plugin for DesignationsPlugin {
39 40 const PRUNE_INTERVAL_TICKS: u64 = 60;
40 41 const DESIGNATION_COLOR: Color = Color::srgba(1.0, 0.85, 0.2, 0.9);
41 42
42 -#[derive(Resource, Default, Debug)]
43 +#[derive(Resource, Default, Debug, Serialize, Deserialize)]
43 44 pub struct Designations(pub HashSet<UVec2>);
44 45
45 46 #[derive(Resource, Default, Debug)]

src/economy.rs +3 -2

@@ -1,3 +1,4 @@
1 +use serde::{Deserialize, Serialize};
1 2 use std::collections::HashMap;
2 3
3 4 use bevy::prelude::*;
@@ -22,7 +23,7 @@ pub const COLONY_RADIUS: i32 = 60;
22 23 const HOURS_OF_MEMORY: f32 = 24.0;
23 24 const MAX_GOAL: f32 = 300.0;
24 25
25 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
26 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
26 27 pub enum Stock {
27 28 Wood,
28 29 Stone,
@@ -113,7 +114,7 @@ impl Stock {
113 114 }
114 115 }
115 116
116 -#[derive(Resource, Debug, Clone)]
117 +#[derive(Resource, Debug, Clone, Serialize, Deserialize)]
117 118 pub struct ColonyGoals {
118 119 goals: HashMap<Stock, u32>,
119 120 use_per_day: HashMap<Stock, f32>,

src/family.rs +3 -2

@@ -1,3 +1,4 @@
1 +use serde::{Deserialize, Serialize};
1 2 use std::collections::{HashMap, HashSet};
2 3
3 4 use bevy::prelude::*;
@@ -68,7 +69,7 @@ impl Child {
68 69 }
69 70 }
70 71
71 -#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
72 73 pub struct Home {
73 74 pub lo: UVec2,
74 75 pub hi: UVec2,
@@ -133,7 +134,7 @@ impl Home {
133 134 }
134 135 }
135 136
136 -#[derive(Resource, Default, Debug)]
137 +#[derive(Resource, Default, Debug, Clone, Serialize, Deserialize)]
137 138 pub struct Homes {
138 139 pub by_family: HashMap<u32, Home>,
139 140 next_family: u32,

src/items.rs +10 -5

@@ -1,4 +1,5 @@
1 1 use bevy::prelude::*;
2 +use serde::{Deserialize, Serialize};
2 3
3 4 use crate::{
4 5 world::{TILE_SIZE, TilePos, WorldMap},
@@ -19,7 +20,7 @@ impl Plugin for ItemsPlugin {
19 20 }
20 21 }
21 22
22 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
23 24 pub enum ItemKind {
24 25 Wood,
25 26 Stone,
@@ -79,7 +80,7 @@ impl ItemKind {
79 80 }
80 81 }
81 82
82 -#[derive(Component, Debug, Clone, Copy, PartialEq)]
83 +#[derive(Component, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
83 84 #[require(Transform)]
84 85 pub struct Item {
85 86 pub kind: ItemKind,
@@ -201,12 +202,16 @@ pub fn drop_items(
201 202 item.merge(dropped);
202 203 return;
203 204 }
205 + spawn_stack(commands, assets, tile, dropped);
206 +}
207 +
208 +pub fn spawn_stack(commands: &mut Commands, assets: &ItemAssets, tile: UVec2, item: Item) {
204 209 commands.spawn((
205 - Name::new(format!("{kind:?}")),
206 - dropped,
210 + Name::new(format!("{:?}", item.kind)),
211 + item,
207 212 TilePos(tile),
208 213 Mesh2d(assets.mesh.clone()),
209 - MeshMaterial2d(assets.material(kind)),
214 + MeshMaterial2d(assets.material(item.kind)),
210 215 Transform::from_translation(WorldMap::tile_center(tile).extend(0.7)),
211 216 ));
212 217 }

src/jobs/mod.rs +3 -2

@@ -1,3 +1,4 @@
1 +use serde::{Deserialize, Serialize};
1 2 use std::{
2 3 collections::{HashMap, HashSet},
3 4 ops::Range,
@@ -82,7 +83,7 @@ const NEIGHBORS: [IVec2; 8] = [
82 83 IVec2::new(-1, -1),
83 84 ];
84 85
85 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
86 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
86 87 pub enum WorkType {
87 88 Food,
88 89 Build,
@@ -114,7 +115,7 @@ impl WorkType {
114 115 }
115 116 }
116 117
117 -#[derive(Component, Default, Debug, Clone, PartialEq, Eq)]
118 +#[derive(Component, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118 119 pub struct WorkPriorities {
119 120 disabled: Vec<WorkType>,
120 121 }

src/main.rs +2 -0

@@ -21,6 +21,7 @@ mod paths;
21 21 mod plants;
22 22 mod population;
23 23 mod rng;
24 +mod save;
24 25 mod selection;
25 26 mod sim;
26 27 mod structures;
@@ -79,5 +80,6 @@ fn main() {
79 80 zones::ZonesPlugin,
80 81 ui::HudPlugin,
81 82 ))
83 + .add_plugins(save::SavePlugin)
82 84 .run();
83 85 }

src/mood.rs +2 -1

@@ -1,4 +1,5 @@
1 1 use bevy::prelude::*;
2 +use serde::{Deserialize, Serialize};
2 3
3 4 use crate::{
4 5 climate::Warmth,
@@ -83,7 +84,7 @@ impl Age {
83 84 }
84 85 }
85 86
86 -#[derive(Resource, Default, Debug)]
87 +#[derive(Resource, Default, Debug, Serialize, Deserialize)]
87 88 pub struct Losses(pub Vec<u64>);
88 89
89 90 fn clear_losses(mut losses: ResMut<Losses>) {

src/population.rs +2 -1

@@ -1,3 +1,4 @@
1 +use serde::{Deserialize, Serialize};
1 2 use std::collections::VecDeque;
2 3
3 4 use bevy::prelude::*;
@@ -38,7 +39,7 @@ const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR;
38 39 const LOG_LENGTH: usize = 4;
39 40 const EDGE_ATTEMPTS: usize = 100;
40 41
41 -#[derive(Resource, Default, Debug)]
42 +#[derive(Resource, Default, Debug, Serialize, Deserialize)]
42 43 pub struct ColonyLog(pub VecDeque<String>);
43 44
44 45 impl ColonyLog {

src/rng.rs +2 -1

@@ -1,8 +1,9 @@
1 +use serde::{Deserialize, Serialize};
1 2 use std::ops::Range;
2 3
3 4 use bevy::prelude::*;
4 5
5 -#[derive(Resource, Clone, Debug)]
6 +#[derive(Resource, Clone, Debug, Serialize, Deserialize)]
6 7 pub struct SimRng(u64);
7 8
8 9 impl SimRng {

src/save.rs +531 -0

@@ -0,0 +1,531 @@
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 +}

src/structures.rs +4 -2

@@ -1,4 +1,5 @@
1 1 use bevy::prelude::*;
2 +use serde::{Deserialize, Serialize};
2 3
3 4 use crate::{
4 5 items::ItemKind,
@@ -20,7 +21,7 @@ impl Plugin for StructuresPlugin {
20 21 }
21 22 }
22 23
23 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
24 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
24 25 pub enum StructureKind {
25 26 Campfire,
26 27 Torch,
@@ -240,7 +241,7 @@ pub fn spawn_structure(
240 241 assets: &StructureAssets,
241 242 tile: UVec2,
242 243 kind: StructureKind,
243 -) {
244 +) -> Entity {
244 245 let mut entity = commands.spawn((
245 246 Name::new(format!("{kind:?}")),
246 247 Structure { kind },
@@ -302,6 +303,7 @@ pub fn spawn_structure(
302 303 ));
303 304 }),
304 305 };
306 + entity.id()
305 307 }
306 308
307 309 #[cfg(test)]

src/ui.rs +3 -1

@@ -36,7 +36,7 @@ impl Plugin for HudPlugin {
36 36 }
37 37 }
38 38
39 -const TOOLBAR: [Action; 14] = [
39 +const TOOLBAR: [Action; 16] = [
40 40 Action::TogglePause,
41 41 Action::SetSpeed(SimSpeed::Normal),
42 42 Action::SetSpeed(SimSpeed::Fast),
@@ -48,6 +48,8 @@ const TOOLBAR: [Action; 14] = [
48 48 Action::ToggleDesignateTool,
49 49 Action::Cancel,
50 50 Action::NewWorld,
51 + Action::Save,
52 + Action::Load,
51 53 Action::ZoomIn,
52 54 Action::ZoomOut,
53 55 Action::ToggleUi,

src/world.rs +91 -0

@@ -1,3 +1,4 @@
1 +use serde::{Deserialize, Serialize};
1 2 use std::collections::HashSet;
2 3
3 4 use bevy::prelude::*;
@@ -93,6 +94,8 @@ pub enum Plant {
93 94 }
94 95
95 96 impl Plant {
97 + pub const ALL: [Plant; 3] = [Plant::Tree, Plant::BerryBush, Plant::Crop];
98 +
96 99 pub fn extra_move_cost(self) -> u32 {
97 100 match self {
98 101 Plant::Tree => 100,
@@ -118,6 +121,28 @@ impl Plant {
118 121 #[derive(Component, Default, Debug, Clone, Copy, PartialEq)]
119 122 pub struct TilePos(pub UVec2);
120 123
124 +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
125 +pub struct MapLayers {
126 + size: UVec2,
127 + terrain: Vec<(u8, u32)>,
128 + plants: Vec<(u8, u32)>,
129 + walls: Vec<(u8, u32)>,
130 + roofs: Vec<(u8, u32)>,
131 + growth: Vec<(u32, f32)>,
132 + wear: Vec<(u32, f32)>,
133 +}
134 +
135 +fn run_length(values: impl Iterator<Item = u8>) -> Vec<(u8, u32)> {
136 + let mut runs: Vec<(u8, u32)> = Vec::new();
137 + for v in values {
138 + match runs.last_mut() {
139 + Some((last, count)) if *last == v => *count += 1,
140 + _ => runs.push((v, 1)),
141 + }
142 + }
143 + runs
144 +}
145 +
121 146 #[derive(Message, Debug, Clone, Copy, PartialEq)]
122 147 pub struct TileChanged(pub UVec2);
123 148
@@ -243,6 +268,72 @@ impl WorldMap {
243 268 }
244 269 }
245 270
271 + pub fn layers(&self) -> MapLayers {
272 + let sparse = |values: &[f32]| {
273 + values
274 + .iter()
275 + .enumerate()
276 + .filter(|(_, v)| **v != 0.0)
277 + .map(|(i, v)| (i as u32, *v))
278 + .collect()
279 + };
280 + MapLayers {
281 + size: self.size,
282 + terrain: run_length(self.tiles.iter().map(|t| t.index() as u8)),
283 + plants: run_length(self.plants.iter().map(|p| p.map_or(0, |p| p as u8 + 1))),
284 + walls: run_length(self.walls.iter().map(|&w| w as u8)),
285 + roofs: run_length(self.roofs.iter().map(|&r| r as u8)),
286 + growth: sparse(&self.growth),
287 + wear: sparse(&self.wear),
288 + }
289 + }
290 +
291 + pub fn restore(&mut self, layers: &MapLayers) -> Result<(), String> {
292 + let n = self.tiles.len();
293 + let unpacked = |runs: &[(u8, u32)]| {
294 + let values: Vec<u8> = runs
295 + .iter()
296 + .flat_map(|&(v, count)| std::iter::repeat_n(v, count as usize))
297 + .collect();
298 + if values.len() == n {
299 + Ok(values)
300 + } else {
301 + Err("map layer has the wrong size".to_string())
302 + }
303 + };
304 + if layers.size != self.size {
305 + return Err("map size differs".into());
306 + }
307 + let terrain = unpacked(&layers.terrain)?;
308 + let plants = unpacked(&layers.plants)?;
309 + let walls = unpacked(&layers.walls)?;
310 + let roofs = unpacked(&layers.roofs)?;
311 + for (i, pos) in self.positions().enumerate() {
312 + let terrain = Terrain::ALL
313 + .get(terrain[i] as usize)
314 + .ok_or("unknown terrain")?;
315 + self.set(pos, *terrain);
316 + let plant = match plants[i] {
317 + 0 => None,
318 + p => Some(*Plant::ALL.get(p as usize - 1).ok_or("unknown plant")?),
319 + };
320 + self.set_plant(pos, plant);
321 + self.set_wall(pos, walls[i] != 0);
322 + self.set_roof(pos, roofs[i] != 0);
323 + self.set_growth(pos, 0.0);
324 + self.set_wear(pos, 0.0);
325 + }
326 + let width = self.size.x;
327 + let at = |i: u32| UVec2::new(i % width, i / width);
328 + for &(i, growth) in &layers.growth {
329 + self.set_growth(at(i), growth);
330 + }
331 + for &(i, wear) in &layers.wear {
332 + self.set_wear(at(i), wear);
333 + }
334 + Ok(())
335 + }
336 +
246 337 pub fn worn_tiles(&self) -> Vec<UVec2> {
247 338 let mut tiles: Vec<UVec2> = self.worn.iter().copied().collect();
248 339 tiles.sort_by_key(|t| (t.y, t.x));

src/zones.rs +28 -2

@@ -1,3 +1,4 @@
1 +use serde::{Deserialize, Serialize};
1 2 use std::collections::{HashMap, HashSet};
2 3
3 4 use bevy::prelude::*;
@@ -39,7 +40,9 @@ pub enum ZoneKind {
39 40 Farm,
40 41 }
41 42
42 -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
43 +#[derive(
44 + Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
45 +)]
43 46 pub enum StoragePriority {
44 47 Low,
45 48 #[default]
@@ -71,7 +74,7 @@ impl StoragePriority {
71 74 }
72 75 }
73 76
74 -#[derive(Clone, Debug, Default, PartialEq)]
77 +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
75 78 pub struct StorageRule {
76 79 pub refused: Vec<Stock>,
77 80 pub priority: StoragePriority,
@@ -103,7 +106,30 @@ pub struct Zones {
103 106 rules: HashMap<UVec2, StorageRule>,
104 107 }
105 108
109 +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
110 +pub struct ZonesSave {
111 + stockpile: Vec<UVec2>,
112 + farm: Vec<UVec2>,
113 + rules: Vec<(UVec2, StorageRule)>,
114 +}
115 +
106 116 impl Zones {
117 + pub fn save(&self) -> ZonesSave {
118 + ZonesSave {
119 + stockpile: self.stockpile.iter().copied().collect(),
120 + farm: self.farm.iter().copied().collect(),
121 + rules: self.rules.iter().map(|(t, r)| (*t, r.clone())).collect(),
122 + }
123 + }
124 +
125 + pub fn load(save: ZonesSave) -> Self {
126 + Self {
127 + stockpile: save.stockpile.into_iter().collect(),
128 + farm: save.farm.into_iter().collect(),
129 + rules: save.rules.into_iter().collect(),
130 + }
131 + }
132 +
107 133 pub fn rule(&self, tile: UVec2) -> &StorageRule {
108 134 self.rules.get(&tile).unwrap_or(&DEFAULT_RULE)
109 135 }

tasks.md +1 -0

@@ -39,6 +39,7 @@
39 39 - [x] Seasons: 12-day year, temperature (season + day/night), crops stop growing in the cold and die in frost, sowing only in the growing season, cold slows spoilage, snow cover
40 40 - [x] Cold: colonist warmth (roofs insulate, fires heat); cold drains food, slows rest, lowers mood; cold colonists warm up by fires; campfires built when chilly, not only dark; food and wood targets cover the cold days ahead
41 41 - [x] Workshops: workbench, iron tools (crafted on demand, equipped, +50% work speed, wear out); gold statues in finished homes lift mood; Craft work type
42 +- [x] Save/load (F5/F9, toolbar buttons): browser localStorage on web, simulator-save.json natively
42 43 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
43 44
44 45 ## Planned