Add seasons, temperature, cold and snow

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 06:21 UTC
commit
74cb0e7c3d1b971ec4ebb56ff9061ced9d19f2ce
parent
954bfe7d77
tree
browse at this commit

16 files changed +544 -26

src/climate.rs +265 -0

@@ -0,0 +1,265 @@
1 +use std::f32::consts::TAU;
2 +
3 +use bevy::prelude::*;
4 +
5 +use crate::{
6 + colonist::Colonist,
7 + light::line_of_sight,
8 + sim::{HOURS_PER_DAY, START_HOUR, SimSystems, SimTick, TICKS_PER_HOUR, hour_of_day},
9 + structures::{Structure, StructureKind},
10 + world::{TilePos, WorldMap},
11 +};
12 +
13 +pub struct ClimatePlugin;
14 +
15 +impl Plugin for ClimatePlugin {
16 + fn build(&self, app: &mut App) {
17 + app.add_systems(FixedUpdate, update_warmth.in_set(SimSystems::Act));
18 + }
19 +}
20 +
21 +pub const SEASON_DAYS: u64 = 3;
22 +pub const YEAR_DAYS: u64 = 4 * SEASON_DAYS;
23 +const MEAN_TEMP: f32 = 12.0;
24 +const YEAR_SWING: f32 = 14.0;
25 +const DAY_SWING: f32 = 4.0;
26 +const COLDEST_YEAR_FRACTION: f32 = 0.79;
27 +const COLDEST_HOUR: f32 = 4.0;
28 +pub const GROWTH_MIN_TEMP: f32 = 4.0;
29 +pub const GROWTH_FULL_TEMP: f32 = 14.0;
30 +pub const GROWING_DAY_TEMP: f32 = 8.0;
31 +pub const SOW_AHEAD_DAYS: u64 = 2;
32 +pub const FROST_TEMP: f32 = -2.0;
33 +pub const COLD_TEMP: f32 = 5.0;
34 +pub const WARM_TEMP: f32 = 10.0;
35 +const INDOOR_TEMP: f32 = 16.0;
36 +const ROOF_INSULATION: f32 = 0.7;
37 +pub const FORESIGHT_DAYS: u64 = 6;
38 +const WARMTH_INTERVAL_TICKS: u64 = 60;
39 +const SNOW_TEMP: f32 = 3.0;
40 +const SNOW_SPAN: f32 = 4.0;
41 +const MAX_SNOW: f32 = 0.75;
42 +
43 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44 +pub enum Season {
45 + Spring,
46 + Summer,
47 + Autumn,
48 + Winter,
49 +}
50 +
51 +impl Season {
52 + pub const ALL: [Season; 4] = [
53 + Season::Spring,
54 + Season::Summer,
55 + Season::Autumn,
56 + Season::Winter,
57 + ];
58 +
59 + pub fn name(self) -> &'static str {
60 + match self {
61 + Season::Spring => "Spring",
62 + Season::Summer => "Summer",
63 + Season::Autumn => "Autumn",
64 + Season::Winter => "Winter",
65 + }
66 + }
67 +}
68 +
69 +fn hours(tick: SimTick) -> f32 {
70 + tick.0 as f32 / TICKS_PER_HOUR as f32 + START_HOUR as f32
71 +}
72 +
73 +pub fn day_of(tick: SimTick) -> u64 {
74 + (tick.0 / TICKS_PER_HOUR + START_HOUR) / HOURS_PER_DAY
75 +}
76 +
77 +pub fn season(tick: SimTick) -> Season {
78 + Season::ALL[(day_of(tick) % YEAR_DAYS / SEASON_DAYS) as usize]
79 +}
80 +
81 +pub fn day_of_season(tick: SimTick) -> u64 {
82 + day_of(tick) % SEASON_DAYS + 1
83 +}
84 +
85 +fn seasonal_temperature(year_days: f32) -> f32 {
86 + let fraction = year_days / YEAR_DAYS as f32;
87 + MEAN_TEMP - YEAR_SWING * (TAU * (fraction - COLDEST_YEAR_FRACTION)).cos()
88 +}
89 +
90 +pub fn outdoor_temperature(tick: SimTick) -> f32 {
91 + let year_days = (hours(tick) / HOURS_PER_DAY as f32) % YEAR_DAYS as f32;
92 + let daily = -DAY_SWING * (TAU * (hour_of_day(tick) - COLDEST_HOUR) / 24.0).cos();
93 + seasonal_temperature(year_days) + daily
94 +}
95 +
96 +pub fn snow_cover(tick: SimTick) -> f32 {
97 + let year_days = (hours(tick) / HOURS_PER_DAY as f32) % YEAR_DAYS as f32;
98 + ((SNOW_TEMP - seasonal_temperature(year_days)) / SNOW_SPAN).clamp(0.0, 1.0) * MAX_SNOW
99 +}
100 +
101 +pub fn daily_mean(day: u64) -> f32 {
102 + seasonal_temperature((day % YEAR_DAYS) as f32 + 0.5)
103 +}
104 +
105 +pub fn growing_day(day: u64) -> bool {
106 + daily_mean(day) >= GROWING_DAY_TEMP
107 +}
108 +
109 +pub fn sowing_season(tick: SimTick) -> bool {
110 + let today = day_of(tick);
111 + (0..SOW_AHEAD_DAYS).all(|d| growing_day(today + d))
112 +}
113 +
114 +pub fn cold_days_ahead(tick: SimTick) -> u64 {
115 + let today = day_of(tick);
116 + (0..FORESIGHT_DAYS)
117 + .filter(|d| !growing_day(today + d))
118 + .count() as u64
119 +}
120 +
121 +pub fn growth_factor(temp: f32) -> f32 {
122 + ((temp - GROWTH_MIN_TEMP) / (GROWTH_FULL_TEMP - GROWTH_MIN_TEMP)).clamp(0.0, 1.0)
123 +}
124 +
125 +pub fn spoil_factor(temp: f32) -> f32 {
126 + (temp / 10.0).clamp(0.1, 1.0)
127 +}
128 +
129 +pub fn heat_at(kind: StructureKind, source: UVec2, tile: UVec2) -> f32 {
130 + let radius = kind.radius();
131 + if radius <= 0.0 {
132 + return 0.0;
133 + }
134 + let d = source.as_vec2().distance(tile.as_vec2()) / radius;
135 + if d >= 1.0 {
136 + return 0.0;
137 + }
138 + kind.heat() * (1.0 - d * d).powi(2)
139 +}
140 +
141 +pub fn tile_temperature(
142 + map: &WorldMap,
143 + outdoor: f32,
144 + tile: UVec2,
145 + fires: impl IntoIterator<Item = (UVec2, StructureKind)>,
146 +) -> f32 {
147 + let base = if map.is_roofed(tile.as_ivec2()) {
148 + outdoor + (INDOOR_TEMP - outdoor).max(0.0) * ROOF_INSULATION
149 + } else {
150 + outdoor
151 + };
152 + base + fires
153 + .into_iter()
154 + .filter(|&(pos, _)| line_of_sight(map, pos, tile))
155 + .map(|(pos, kind)| heat_at(kind, pos, tile))
156 + .sum::<f32>()
157 +}
158 +
159 +#[derive(Component, Debug, Clone, Copy, PartialEq)]
160 +pub struct Warmth(pub f32);
161 +
162 +impl Default for Warmth {
163 + fn default() -> Self {
164 + Self(INDOOR_TEMP)
165 + }
166 +}
167 +
168 +impl Warmth {
169 + pub fn cold(self) -> bool {
170 + self.0 < COLD_TEMP
171 + }
172 +}
173 +
174 +fn update_warmth(
175 + tick: Res<SimTick>,
176 + map: Res<WorldMap>,
177 + structures: Query<(&TilePos, &Structure), Without<Colonist>>,
178 + mut colonists: Query<(&TilePos, &mut Warmth), With<Colonist>>,
179 +) {
180 + if !tick.0.is_multiple_of(WARMTH_INTERVAL_TICKS) {
181 + return;
182 + }
183 + let outdoor = outdoor_temperature(*tick);
184 + let fires: Vec<(UVec2, StructureKind)> = structures
185 + .iter()
186 + .filter(|(_, s)| s.kind.heat() > 0.0)
187 + .map(|(pos, s)| (pos.0, s.kind))
188 + .collect();
189 + for (tile, mut warmth) in &mut colonists {
190 + let temp = tile_temperature(&map, outdoor, tile.0, fires.iter().copied());
191 + warmth.set_if_neq(Warmth(temp));
192 + }
193 +}
194 +
195 +#[cfg(test)]
196 +mod tests {
197 + use super::*;
198 + use crate::world::Terrain;
199 +
200 + fn at_day(day: f32) -> SimTick {
201 + let hours = day * 24.0 - START_HOUR as f32;
202 + SimTick((hours * TICKS_PER_HOUR as f32) as u64)
203 + }
204 +
205 + #[test]
206 + fn the_year_runs_spring_summer_autumn_winter() {
207 + assert_eq!(season(SimTick(0)), Season::Spring);
208 + assert_eq!(season(at_day(4.0)), Season::Summer);
209 + assert_eq!(season(at_day(7.0)), Season::Autumn);
210 + assert_eq!(season(at_day(10.0)), Season::Winter);
211 + assert_eq!(season(at_day(12.5)), Season::Spring);
212 + assert_eq!(day_of_season(at_day(4.5)), 2);
213 + }
214 +
215 + #[test]
216 + fn summer_is_warm_winter_freezes_and_nights_are_colder() {
217 + assert!(outdoor_temperature(at_day(4.5)) > 20.0);
218 + assert!(outdoor_temperature(at_day(9.0 + 4.0 / 24.0)) < -5.0);
219 + let night = outdoor_temperature(at_day(4.0 + 4.0 / 24.0));
220 + let afternoon = outdoor_temperature(at_day(4.0 + 16.0 / 24.0));
221 + assert!(afternoon > night + 5.0);
222 + }
223 +
224 + // Crops need a run of warm days to ripen (1.5 days at full speed), so sowing stops before the
225 + // cold comes and colonists start storing up for the days without harvests.
226 + #[test]
227 + fn sowing_stops_before_winter_and_stores_are_planned_ahead() {
228 + assert!(sowing_season(at_day(4.0)));
229 + assert!(!sowing_season(at_day(10.0)));
230 + assert!(!sowing_season(at_day(7.5)));
231 + assert_eq!(cold_days_ahead(at_day(1.0)), 0);
232 + assert!(cold_days_ahead(at_day(6.5)) >= 3);
233 + assert!(cold_days_ahead(at_day(9.5)) > cold_days_ahead(at_day(12.5)));
234 + }
235 +
236 + #[test]
237 + fn cold_slows_growth_and_spoilage() {
238 + assert_eq!(growth_factor(0.0), 0.0);
239 + assert_eq!(growth_factor(20.0), 1.0);
240 + assert_eq!(spoil_factor(25.0), 1.0);
241 + assert!(spoil_factor(-5.0) < 0.2);
242 + }
243 +
244 + #[test]
245 + fn snow_lies_only_in_winter() {
246 + assert_eq!(snow_cover(at_day(4.5)), 0.0);
247 + assert!(snow_cover(at_day(9.5)) > 0.5);
248 + }
249 +
250 + // A roof alone keeps a mild winter bearable; outside only a fire keeps colonists warm, and
251 + // walls block its heat like they block its light.
252 + #[test]
253 + fn roofs_and_fires_keep_colonists_warm() {
254 + let mut map = WorldMap::filled(UVec2::new(20, 5), Terrain::Grass);
255 + map.set_roof(UVec2::new(0, 0), true);
256 + let winter = -6.0;
257 + let none = std::iter::empty();
258 + assert!(tile_temperature(&map, winter, UVec2::new(0, 0), none.clone()) >= COLD_TEMP);
259 + assert!(tile_temperature(&map, winter, UVec2::new(5, 0), none) < COLD_TEMP);
260 + let fire = [(UVec2::new(10, 2), StructureKind::Campfire)];
261 + assert!(tile_temperature(&map, winter, UVec2::new(11, 2), fire) >= WARM_TEMP);
262 + map.set_wall(UVec2::new(12, 2), true);
263 + assert!(tile_temperature(&map, winter, UVec2::new(13, 2), fire) < COLD_TEMP);
264 + }
265 +}

src/colonist.rs +3 -1

@@ -3,6 +3,7 @@ use std::collections::VecDeque;
3 3 use bevy::prelude::*;
4 4
5 5 use crate::{
6 + climate::Warmth,
6 7 food::Hunger,
7 8 items::{Carrying, ItemAssets},
8 9 jobs::{CurrentJob, IdleTimer, WorkPriorities},
@@ -90,7 +91,8 @@ pub fn spawn_colonist(
90 91 Rest,
91 92 Hunger,
92 93 Starvation,
93 - WorkPriorities
94 + WorkPriorities,
95 + Warmth
94 96 )]
95 97 pub struct Colonist {
96 98 pub name: String,

src/economy.rs +29 -3

@@ -4,6 +4,7 @@ use bevy::prelude::*;
4 4
5 5 use crate::{
6 6 building::Blueprints,
7 + climate::cold_days_ahead,
7 8 items::ItemKind,
8 9 sim::{SimSystems, SimTick, TICKS_PER_HOUR},
9 10 };
@@ -75,8 +76,12 @@ impl Stock {
75 76 }
76 77 }
77 78
78 - fn buffer_days(self) -> f32 {
79 - if self.is_food() { 1.5 } else { 2.0 }
79 + fn buffer_days(self, cold_days: u64) -> f32 {
80 + let normal: f32 = if self.is_food() { 1.5 } else { 2.0 };
81 + match self {
82 + Stock::Wood | Stock::RawFood | Stock::Meals => normal.max(cold_days as f32),
83 + _ => normal,
84 + }
80 85 }
81 86
82 87 fn minimum(self) -> f32 {
@@ -106,6 +111,7 @@ pub struct ColonyGoals {
106 111 use_per_day: HashMap<Stock, f32>,
107 112 used_this_hour: HashMap<Stock, u32>,
108 113 overrides: HashMap<Stock, u32>,
114 + cold_days: u64,
109 115 }
110 116
111 117 impl Default for ColonyGoals {
@@ -118,6 +124,7 @@ impl Default for ColonyGoals {
118 124 .collect(),
119 125 used_this_hour: HashMap::new(),
120 126 overrides: HashMap::new(),
127 + cold_days: 0,
121 128 };
122 129 goals.recompute(&HashMap::new());
123 130 goals
@@ -132,6 +139,7 @@ impl ColonyGoals {
132 139 use_per_day: HashMap::new(),
133 140 used_this_hour: HashMap::new(),
134 141 overrides: HashMap::new(),
142 + cold_days: 0,
135 143 }
136 144 }
137 145
@@ -180,6 +188,10 @@ impl ColonyGoals {
180 188 have < self.goal(stock)
181 189 }
182 190
191 + pub fn set_cold_days(&mut self, days: u64) {
192 + self.cold_days = days;
193 + }
194 +
183 195 pub fn learn_hour(&mut self, planned: &HashMap<Stock, u32>) {
184 196 for stock in Stock::ALL {
185 197 let used = self.used_this_hour.remove(&stock).unwrap_or(0) as f32;
@@ -191,7 +203,7 @@ impl ColonyGoals {
191 203
192 204 fn recompute(&mut self, planned: &HashMap<Stock, u32>) {
193 205 for stock in Stock::ALL {
194 - let learned = (self.use_per_day(stock) * stock.buffer_days())
206 + let learned = (self.use_per_day(stock) * stock.buffer_days(self.cold_days))
195 207 .clamp(stock.minimum(), MAX_GOAL)
196 208 .ceil() as u32;
197 209 let goal = learned + planned.get(&stock).copied().unwrap_or(0);
@@ -217,6 +229,7 @@ fn learn_needs(tick: Res<SimTick>, blueprints: Res<Blueprints>, mut goals: ResMu
217 229 if tick.0 == 0 || !tick.0.is_multiple_of(TICKS_PER_HOUR) {
218 230 return;
219 231 }
232 + goals.set_cold_days(cold_days_ahead(*tick));
220 233 goals.learn_hour(&planned_needs(&blueprints));
221 234 }
222 235
@@ -257,6 +270,19 @@ mod tests {
257 270 assert!(goals.goal(Stock::Wood) > learned);
258 271 }
259 272
273 + // Nothing grows in the cold days ahead and fires burn all day, so food and wood targets cover
274 + // those days instead of the usual short buffer.
275 + #[test]
276 + fn winter_ahead_raises_food_and_wood_targets() {
277 + let mut goals = ColonyGoals::default();
278 + learn_days(&mut goals, ItemKind::Berries, 1, 3);
279 + let (food, stone) = (goals.goal(Stock::RawFood), goals.goal(Stock::Stone));
280 + goals.set_cold_days(5);
281 + goals.learn_hour(&HashMap::new());
282 + assert!(goals.goal(Stock::RawFood) > food * 3);
283 + assert_eq!(goals.goal(Stock::Stone), stone);
284 + }
285 +
260 286 #[test]
261 287 fn unused_materials_are_not_stockpiled() {
262 288 let mut goals = ColonyGoals::default();

src/food.rs +10 -3

@@ -3,6 +3,7 @@ use std::collections::HashSet;
3 3 use bevy::prelude::*;
4 4
5 5 use crate::{
6 + climate::{Warmth, outdoor_temperature, spoil_factor},
6 7 colonist::{Colonist, Path},
7 8 items::{Carrying, Item, ItemKind},
8 9 jobs::{CurrentJob, Reservations, Target},
@@ -26,6 +27,7 @@ impl Plugin for FoodPlugin {
26 27 pub const HUNGER_PER_TICK: f32 = 1.0 / (24.0 * TICKS_PER_HOUR as f32);
27 28 pub const SLEEPING_HUNGER_FACTOR: f32 = 0.5;
28 29 pub const HUNGRY: f32 = 0.35;
30 +pub const COLD_HUNGER_FACTOR: f32 = 1.5;
29 31 pub const STARVING: f32 = 0.05;
30 32 pub const EAT_TICKS: u32 = 90;
31 33 pub const ROOFED_SPOIL_FACTOR: f32 = 0.5;
@@ -98,7 +100,7 @@ pub fn spoil_food(
98 100 if !tick.0.is_multiple_of(SPOIL_INTERVAL_TICKS) {
99 101 return;
100 102 }
101 - let ticks = SPOIL_INTERVAL_TICKS as f32;
103 + let ticks = SPOIL_INTERVAL_TICKS as f32 * spoil_factor(outdoor_temperature(*tick));
102 104 for (entity, mut item, pos) in &mut items {
103 105 item.spoil += spoilage(item.kind, map.is_roofed(pos.0.as_ivec2()), ticks);
104 106 if item.spoil >= 1.0 {
@@ -116,14 +118,19 @@ pub fn spoil_food(
116 118 }
117 119
118 120 pub fn update_hunger(
119 - mut colonists: Query<(&mut Hunger, &CurrentJob, &TilePos, &Path), With<Colonist>>,
121 + mut colonists: Query<(&mut Hunger, &CurrentJob, &TilePos, &Path, &Warmth), With<Colonist>>,
120 122 ) {
121 - for (mut hunger, job, tile, path) in &mut colonists {
123 + for (mut hunger, job, tile, path, warmth) in &mut colonists {
122 124 let rate = if is_sleeping(job, tile, path) {
123 125 HUNGER_PER_TICK * SLEEPING_HUNGER_FACTOR
124 126 } else {
125 127 HUNGER_PER_TICK
126 128 };
129 + let rate = if warmth.cold() {
130 + rate * COLD_HUNGER_FACTOR
131 + } else {
132 + rate
133 + };
127 134 hunger.0 = (hunger.0 - rate).max(0.0);
128 135 }
129 136 }

src/jobs/mod.rs +61 -5

@@ -9,6 +9,7 @@ use bevy::prelude::*;
9 9 use crate::{
10 10 animals::{Animal, AnimalKind, MAX_TAME, Tame},
11 11 building::{Blueprints, BuildKind},
12 + climate::{COLD_TEMP, WARM_TEMP, Warmth, outdoor_temperature, sowing_season, tile_temperature},
12 13 colonist::{Colonist, Path, repath},
13 14 designations::Designations,
14 15 economy::{ColonyGoals, in_colony},
@@ -18,7 +19,10 @@ use crate::{
18 19 CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, STACK_LIMIT, drop_items,
19 20 room_for, stock, take_items,
20 21 },
21 - light::{LIT_BRIGHTNESS, LightMap, brightness, dark_soon, light_from, line_of_sight},
22 + light::{
23 + LIGHT_LOOKAHEAD_TICKS, LIT_BRIGHTNESS, LightMap, brightness, dark_soon, light_from,
24 + line_of_sight,
25 + },
22 26 mood::{Ate, Sick},
23 27 needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep},
24 28 pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_of, nearest_tile},
@@ -885,10 +889,12 @@ pub fn find_cooking(ctx: &BuildContext, from: UVec2) -> Option<Task> {
885 889 })
886 890 }
887 891
888 -pub fn find_lighting(ctx: &BuildContext, from: UVec2) -> Option<Task> {
892 +pub fn find_lighting(ctx: &BuildContext, from: UVec2, dark: bool) -> Option<Task> {
889 893 let home = ctx.zones.home(from);
890 894 let (kind, site) = if ctx.campfire_needed(home) {
891 895 (StructureKind::Campfire, ctx.site_near(home, HOME_RADIUS)?)
896 + } else if !dark {
897 + return None;
892 898 } else {
893 899 let dark = ctx.darkest_stockpile_tile(home)?;
894 900 let reach = StructureKind::Torch.radius() * 0.6;
@@ -897,6 +903,37 @@ pub fn find_lighting(ctx: &BuildContext, from: UVec2) -> Option<Task> {
897 903 ctx.build_task(from, site, BuildKind::Structure(kind))
898 904 }
899 905
906 +pub fn chilly_soon(tick: SimTick) -> bool {
907 + let later = SimTick(tick.0 + LIGHT_LOOKAHEAD_TICKS);
908 + outdoor_temperature(tick) < COLD_TEMP || outdoor_temperature(later) < COLD_TEMP
909 +}
910 +
911 +pub fn find_warm_spot(
912 + map: &WorldMap,
913 + structures: &[(UVec2, StructureKind, u32)],
914 + outdoor: f32,
915 + from: UVec2,
916 +) -> Option<UVec2> {
917 + let fires: Vec<(UVec2, StructureKind)> = structures
918 + .iter()
919 + .filter(|(_, kind, _)| kind.heat() > 0.0)
920 + .map(|&(pos, kind, _)| (pos, kind))
921 + .collect();
922 + let taken: HashSet<UVec2> = structures.iter().map(|(pos, ..)| *pos).collect();
923 + let warm: HashSet<UVec2> = fires
924 + .iter()
925 + .flat_map(|&(pos, kind)| {
926 + let r = kind.radius().ceil() as i32;
927 + (-r..=r).flat_map(move |dy| (-r..=r).map(move |dx| pos.as_ivec2() + IVec2::new(dx, dy)))
928 + })
929 + .filter(|&p| map.in_bounds(p) && map.move_cost(p).is_some())
930 + .map(|p| p.as_uvec2())
931 + .filter(|p| !taken.contains(p))
932 + .filter(|&p| tile_temperature(map, outdoor, p, fires.iter().copied()) >= WARM_TEMP)
933 + .collect();
934 + nearest_of(map, from, SEARCH_TILES, &warm)
935 +}
936 +
900 937 pub fn find_refuel(ctx: &BuildContext, from: UVec2) -> Option<Task> {
901 938 let home = ctx.zones.home(from);
902 939 let (site, kind, _) = ctx
@@ -986,6 +1023,7 @@ fn choose_jobs(
986 1023 Option<&Family>,
987 1024 Has<Child>,
988 1025 &WorkPriorities,
1026 + &Warmth,
989 1027 ),
990 1028 With<Colonist>,
991 1029 >,
@@ -1025,6 +1063,9 @@ fn choose_jobs(
1025 1063 })
1026 1064 .collect();
1027 1065 let dark = dark_soon(*tick);
1066 + let chilly = chilly_soon(*tick);
1067 + let outdoor = outdoor_temperature(*tick);
1068 + let sowing = sowing_season(*tick);
1028 1069 let carried: Vec<Item> = colonists.iter().filter_map(|c| c.5.0).collect();
1029 1070 let home = zones.home(map.size / 2);
1030 1071 let stocks: HashMap<ItemKind, u32> = ItemKind::ALL
@@ -1064,6 +1105,7 @@ fn choose_jobs(
1064 1105 family,
1065 1106 child,
1066 1107 priorities,
1108 + warmth,
1067 1109 ) in &mut colonists
1068 1110 {
1069 1111 let exhausted = rest.0 < EXHAUSTED_REST;
@@ -1116,6 +1158,16 @@ fn choose_jobs(
1116 1158 Some(Task::Sleep {
1117 1159 spot: sleep.spot(tile.0),
1118 1160 })
1161 + } else if let Some(spot) = warmth
1162 + .cold()
1163 + .then(|| find_warm_spot(&map, &structures_list, outdoor, tile.0))
1164 + .flatten()
1165 + .filter(|&spot| spot != tile.0)
1166 + {
1167 + find_path(&map, tile.0, spot).map(|route| {
1168 + path.0 = route.into();
1169 + Task::MoveTo(spot)
1170 + })
1119 1171 } else if child {
1120 1172 None
1121 1173 } else if let Some(load) = carrying.0 {
@@ -1162,7 +1214,9 @@ fn choose_jobs(
1162 1214 }),
1163 1215 (WorkType::Build, &|| find_refuel(&build, tile.0)),
1164 1216 (WorkType::Build, &|| {
1165 - dark.then(|| find_lighting(&build, tile.0)).flatten()
1217 + (dark || chilly)
1218 + .then(|| find_lighting(&build, tile.0, dark))
1219 + .flatten()
1166 1220 }),
1167 1221 (WorkType::Food, &cooking),
1168 1222 (WorkType::Food, &|| {
@@ -1180,7 +1234,7 @@ fn choose_jobs(
1180 1234 }),
1181 1235 (WorkType::Gather, &designated),
1182 1236 (WorkType::Food, &|| {
1183 - wanted(ItemKind::Vegetables)
1237 + (sowing && wanted(ItemKind::Vegetables))
1184 1238 .then(|| find_sowing(&map, &zones, &reservations, tile.0))
1185 1239 .flatten()
1186 1240 }),
@@ -1199,7 +1253,9 @@ fn choose_jobs(
1199 1253 .flatten()
1200 1254 }),
1201 1255 (WorkType::Food, &|| {
1202 - find_sowing(&map, &zones, &reservations, tile.0)
1256 + sowing
1257 + .then(|| find_sowing(&map, &zones, &reservations, tile.0))
1258 + .flatten()
1203 1259 }),
1204 1260 (WorkType::Tame, &|| {
1205 1261 (!wanted(ItemKind::Meat))

src/jobs/tests.rs +33 -0

@@ -1388,3 +1388,36 @@ fn stored_items_move_up_to_a_higher_priority_stockpile() {
1388 1388 run_ticks(&mut app, 60 * 20);
1389 1389 assert!(wood_at(&mut app).iter().all(|t| t.x >= 14 && t.y >= 14));
1390 1390 }
1391 +
1392 +// Cold colonists stop to warm up by a fire before taking new work; they would otherwise lose
1393 +// mood, rest and food to the cold for as long as winter lasts.
1394 +#[test]
1395 +fn cold_colonists_warm_up_by_a_fire() {
1396 + let mut app = test_app(WorldMap::filled(UVec2::splat(30), Terrain::Grass));
1397 + let winter = 9 * 24 * crate::sim::TICKS_PER_HOUR;
1398 + app.insert_resource(SimTick(winter));
1399 + app.world_mut().spawn((
1400 + Structure {
1401 + kind: StructureKind::Campfire,
1402 + },
1403 + TilePos(UVec2::new(20, 20)),
1404 + ));
1405 + let colonist = spawn_colonist(&mut app, UVec2::new(2, 2));
1406 + app.world_mut()
1407 + .entity_mut(colonist)
1408 + .insert(crate::climate::Warmth(-5.0));
1409 + for _ in 0..300 {
1410 + run_ticks(&mut app, 1);
1411 + if job(&app, colonist).is_some() {
1412 + break;
1413 + }
1414 + }
1415 + let Some(Job {
1416 + task: Task::MoveTo(spot),
1417 + ..
1418 + }) = job(&app, colonist)
1419 + else {
1420 + panic!("expected a move to the fire, got {:?}", job(&app, colonist));
1421 + };
1422 + assert!(spot.as_vec2().distance(Vec2::splat(20.0)) <= 3.0);
1423 +}

src/light.rs +21 -2

@@ -1,6 +1,7 @@
1 1 use bevy::prelude::*;
2 2
3 3 use crate::{
4 + climate::snow_cover,
4 5 sim::{SimSystems, SimTick, TICKS_PER_HOUR, hour_of_day},
5 6 structures::{Structure, StructureKind},
6 7 world::{TileChanged, TilePos, WorldMap},
@@ -21,6 +22,7 @@ const AMBIENT_STEPS: f32 = 16.0;
21 22 const DAY_COLOR: Vec3 = Vec3::ONE;
22 23 const NIGHT_COLOR: Vec3 = Vec3::new(0.2, 0.24, 0.4);
23 24 pub const ROOF_DAYLIGHT: f32 = 0.3;
25 +const SNOW_STEPS: f32 = 8.0;
24 26
25 27 pub fn daylight(hour: f32) -> f32 {
26 28 match hour {
@@ -80,6 +82,7 @@ pub struct LightMap {
80 82 indoor_ambient: Vec3,
81 83 sources: Vec<Vec3>,
82 84 roofed: Vec<bool>,
85 + snow_step: u32,
83 86 }
84 87
85 88 impl LightMap {
@@ -96,6 +99,15 @@ impl LightMap {
96 99 (ambient + self.sources[index]).min(Vec3::ONE)
97 100 }
98 101
102 + pub fn snow(&self) -> f32 {
103 + self.snow_step as f32 / SNOW_STEPS
104 + }
105 +
106 + pub fn with_snow(mut self, cover: f32) -> Self {
107 + self.snow_step = (cover * SNOW_STEPS).round() as u32;
108 + self
109 + }
110 +
99 111 pub fn is_lit(&self, tile: UVec2) -> bool {
100 112 brightness(self.color(tile)) >= LIT_BRIGHTNESS
101 113 }
@@ -141,6 +153,7 @@ impl LightMap {
141 153 .positions()
142 154 .map(|p| map.is_roofed(p.as_ivec2()))
143 155 .collect(),
156 + snow_step: 0,
144 157 }
145 158 }
146 159 }
@@ -158,10 +171,16 @@ fn update_light_map(
158 171 let step = (day * AMBIENT_STEPS).round() as u32;
159 172 let tiles_changed = changed.read().count() > 0;
160 173 let sources_changed = !added.is_empty() || removed.read().count() > 0 || tiles_changed;
161 - if !sources_changed && step == light.ambient_step && light.size == map.size {
174 + let snow = LightMap::default().with_snow(snow_cover(*tick)).snow_step;
175 + if !sources_changed
176 + && step == light.ambient_step
177 + && snow == light.snow_step
178 + && light.size == map.size
179 + {
162 180 return;
163 181 }
164 - let fresh = LightMap::compute(&map, day, structures.iter().map(|(pos, s)| (pos.0, s.kind)));
182 + let fresh = LightMap::compute(&map, day, structures.iter().map(|(pos, s)| (pos.0, s.kind)))
183 + .with_snow(snow_cover(*tick));
165 184 if fresh != *light {
166 185 *light = fresh;
167 186 }

src/main.rs +2 -0

@@ -2,6 +2,7 @@ mod actions;
2 2 mod animals;
3 3 mod building;
4 4 mod camera;
5 +mod climate;
5 6 mod colonist;
6 7 mod designations;
7 8 mod economy;
@@ -73,6 +74,7 @@ fn main() {
73 74 mood::MoodPlugin,
74 75 panel::PanelPlugin,
75 76 plants::PlantsPlugin,
77 + climate::ClimatePlugin,
76 78 selection::SelectionPlugin,
77 79 zones::ZonesPlugin,
78 80 ui::HudPlugin,

src/mood.rs +9 -1

@@ -1,6 +1,7 @@
1 1 use bevy::prelude::*;
2 2
3 3 use crate::{
4 + climate::Warmth,
4 5 colonist::Colonist,
5 6 family::{Child, Family, Homes, Partner},
6 7 food::{HUNGRY, Hunger},
@@ -105,6 +106,7 @@ pub struct MoodFactors {
105 106 pub partner: bool,
106 107 pub home: bool,
107 108 pub grieving: bool,
109 + pub cold: bool,
108 110 }
109 111
110 112 pub fn mood_target(f: MoodFactors) -> f32 {
@@ -128,6 +130,9 @@ pub fn mood_target(f: MoodFactors) -> f32 {
128 130 if f.grieving {
129 131 mood -= 0.15;
130 132 }
133 + if f.cold {
134 + mood -= 0.15;
135 + }
131 136 mood.clamp(0.0, 1.0)
132 137 }
133 138
@@ -188,13 +193,14 @@ fn update_mood(
188 193 Option<&LastMeal>,
189 194 Has<Partner>,
190 195 Option<&Family>,
196 + &Warmth,
191 197 )>,
192 198 ) {
193 199 if !tick.0.is_multiple_of(TICKS_PER_HOUR) {
194 200 return;
195 201 }
196 202 let grieving = losses.0.iter().any(|&t| tick.0 < t + GRIEF_TICKS);
197 - for (entity, mut mood, hunger, rest, sick, meal, partner, family) in &mut colonists {
203 + for (entity, mut mood, hunger, rest, sick, meal, partner, family, warmth) in &mut colonists {
198 204 let sick_now = sick.is_some_and(|s| tick.0 < s.until);
199 205 if sick.is_some() && !sick_now {
200 206 commands.entity(entity).remove::<Sick>();
@@ -212,6 +218,7 @@ fn update_mood(
212 218 partner,
213 219 home,
214 220 grieving,
221 + cold: warmth.cold(),
215 222 });
216 223 mood.0 += (target - mood.0) / MOOD_HOURS;
217 224 }
@@ -329,6 +336,7 @@ mod tests {
329 336 rest: 0.1,
330 337 sick: true,
331 338 grieving: true,
339 + cold: true,
332 340 last_meal_cooked: Some(false),
333 341 ..default()
334 342 });

src/needs.rs +9 -2

@@ -4,6 +4,7 @@ use bevy::prelude::*;
4 4
5 5 use crate::{
6 6 building::Blueprints,
7 + climate::Warmth,
7 8 colonist::{Colonist, Path},
8 9 family::{Family, Homes},
9 10 items::Item,
@@ -28,6 +29,7 @@ pub const REST_DRAIN_PER_TICK: f32 = 1.0 / (24.0 * TICKS_PER_HOUR as f32);
28 29 pub const REST_RECOVERY_PER_TICK: f32 = 1.0 / (7.0 * TICKS_PER_HOUR as f32);
29 30 pub const BED_REST_RECOVERY_PER_TICK: f32 = 1.0 / (5.0 * TICKS_PER_HOUR as f32);
30 31 pub const ROOF_REST_BONUS: f32 = 1.25;
32 +pub const COLD_REST_FACTOR: f32 = 0.5;
31 33 pub const BEDTIME_REST: f32 = 0.9;
32 34 pub const NAP_REST: f32 = 0.3;
33 35 pub const EXHAUSTED_REST: f32 = 0.1;
@@ -67,14 +69,14 @@ pub fn is_sleeping(job: &CurrentJob, tile: &TilePos, path: &Path) -> bool {
67 69 pub fn update_rest(
68 70 map: Res<WorldMap>,
69 71 structures: Query<(&TilePos, &Structure), Without<Colonist>>,
70 - mut colonists: Query<(&mut Rest, &CurrentJob, &TilePos, &Path), With<Colonist>>,
72 + mut colonists: Query<(&mut Rest, &CurrentJob, &TilePos, &Path, &Warmth), With<Colonist>>,
71 73 ) {
72 74 let beds: HashSet<UVec2> = structures
73 75 .iter()
74 76 .filter(|(_, s)| s.kind == StructureKind::Bed)
75 77 .map(|(pos, _)| pos.0)
76 78 .collect();
77 - for (mut rest, job, tile, path) in &mut colonists {
79 + for (mut rest, job, tile, path, warmth) in &mut colonists {
78 80 let change = match (is_sleeping(job, tile, path), beds.contains(&tile.0)) {
79 81 (true, true) => BED_REST_RECOVERY_PER_TICK,
80 82 (true, false) => REST_RECOVERY_PER_TICK,
@@ -85,6 +87,11 @@ pub fn update_rest(
85 87 } else {
86 88 change
87 89 };
90 + let change = if change > 0.0 && warmth.cold() {
91 + change * COLD_REST_FACTOR
92 + } else {
93 + change
94 + };
88 95 rest.0 = (rest.0 + change).clamp(0.0, 1.0);
89 96 }
90 97 }

src/plants.rs +37 -5

@@ -1,6 +1,7 @@
1 1 use bevy::prelude::*;
2 2
3 3 use crate::{
4 + climate::{FROST_TEMP, growth_factor, outdoor_temperature},
4 5 sim::{SimSystems, SimTick},
5 6 tilemap::PlantLook,
6 7 world::{Terrain, TileChanged, WorldMap},
@@ -27,7 +28,13 @@ pub fn fertility(terrain: Terrain) -> f32 {
27 28 }
28 29 }
29 30
30 -pub fn grow(map: &mut WorldMap, elapsed_ticks: f32, mut on_change: impl FnMut(UVec2)) {
31 +pub fn grow(
32 + map: &mut WorldMap,
33 + elapsed_ticks: f32,
34 + temperature: f32,
35 + mut on_change: impl FnMut(UVec2),
36 +) {
37 + let warmth = growth_factor(temperature);
31 38 for pos in map.growing_plants() {
32 39 let p = pos.as_ivec2();
33 40 let Some(plant) = map.plant(p) else {
@@ -40,12 +47,17 @@ pub fn grow(map: &mut WorldMap, elapsed_ticks: f32, mut on_change: impl FnMut(UV
40 47 if growth >= 1.0 {
41 48 continue;
42 49 }
50 + if plant == crate::world::Plant::Crop && temperature < FROST_TEMP {
51 + map.set_plant(pos, None);
52 + on_change(pos);
53 + continue;
54 + }
43 55 let soil = fertility(map.get(p).unwrap_or(Terrain::Grass));
44 56 let soil = match plant {
45 57 crate::world::Plant::Crop => soil,
46 58 _ => soil.max(WILD_MIN_FERTILITY),
47 59 };
48 - let grown = (growth + elapsed_ticks / ripening * soil).min(1.0);
60 + let grown = (growth + elapsed_ticks / ripening * soil * warmth).min(1.0);
49 61 map.set_growth(pos, grown);
50 62 if PlantLook::of(plant, growth) != PlantLook::of(plant, grown) {
51 63 on_change(pos);
@@ -61,7 +73,8 @@ fn grow_plants(
61 73 if !tick.0.is_multiple_of(GROWTH_INTERVAL_TICKS) {
62 74 return;
63 75 }
64 - grow(&mut map, GROWTH_INTERVAL_TICKS as f32, |pos| {
76 + let temperature = outdoor_temperature(*tick);
77 + grow(&mut map, GROWTH_INTERVAL_TICKS as f32, temperature, |pos| {
65 78 changed.write(TileChanged(pos));
66 79 });
67 80 }
@@ -71,10 +84,12 @@ mod tests {
71 84 use super::*;
72 85 use crate::world::Plant;
73 86
87 + const SUMMER: f32 = 20.0;
88 +
74 89 fn growth_after(terrain: Terrain, plant: Plant, ticks: f32) -> f32 {
75 90 let mut map = WorldMap::filled(UVec2::splat(2), terrain);
76 91 map.set_plant(UVec2::ZERO, Some(plant));
77 - grow(&mut map, ticks, |_| {});
92 + grow(&mut map, ticks, SUMMER, |_| {});
78 93 map.growth(IVec2::ZERO)
79 94 }
80 95
@@ -94,7 +109,24 @@ mod tests {
94 109 let mut map = WorldMap::filled(UVec2::splat(2), Terrain::Soil);
95 110 map.set_plant(UVec2::ZERO, Some(Plant::Crop));
96 111 let mut changes = 0;
97 - grow(&mut map, 1e9, |_| changes += 1);
112 + grow(&mut map, 1e9, SUMMER, |_| changes += 1);
98 113 assert_eq!(changes, 1);
99 114 }
115 +
116 + // Unripe crops die in a hard frost, so the harvest has to be in before winter; ripe crops and
117 + // wild bushes survive but stop growing.
118 + #[test]
119 + fn frost_kills_unripe_crops_and_cold_stops_growth() {
120 + let mut map = WorldMap::filled(UVec2::new(3, 1), Terrain::Soil);
121 + map.set_plant(UVec2::new(0, 0), Some(Plant::Crop));
122 + map.set_plant(UVec2::new(1, 0), Some(Plant::Crop));
123 + map.set_growth(UVec2::new(1, 0), 1.0);
124 + map.set_plant(UVec2::new(2, 0), Some(Plant::BerryBush));
125 + grow(&mut map, 1000.0, 2.0, |_| {});
126 + assert_eq!(map.growth(IVec2::new(2, 0)), 0.0);
127 + grow(&mut map, 1000.0, FROST_TEMP - 1.0, |_| {});
128 + assert_eq!(map.plant(IVec2::new(0, 0)), None);
129 + assert_eq!(map.plant(IVec2::new(1, 0)), Some(Plant::Crop));
130 + assert_eq!(map.plant(IVec2::new(2, 0)), Some(Plant::BerryBush));
131 + }
100 132 }

src/selection.rs +15 -3

@@ -5,6 +5,7 @@ use crate::{
5 5 animals::{Animal, Tame},
6 6 building::{Blueprints, BuildMode, BuildTool},
7 7 camera::CameraController,
8 + climate::{Warmth, day_of_season, outdoor_temperature, season},
8 9 colonist::{Colonist, Path},
9 10 designations::DesignateTool,
10 11 economy::{ColonyGoals, Stock, in_colony},
@@ -278,6 +279,7 @@ fn update_tile_info(
278 279 Option<&Mood>,
279 280 Option<&Age>,
280 281 Has<Sick>,
282 + Option<&Warmth>,
281 283 )>,
282 284 mut text: Single<&mut Text, With<TileInfoText>>,
283 285 ) {
@@ -298,7 +300,7 @@ fn update_tile_info(
298 300 fed: hunger.0,
299 301 }) + &kin
300 302 .get(entity)
301 - .map(|(partner, child, mood, age, sick)| {
303 + .map(|(partner, child, mood, age, sick, warmth)| {
302 304 let partner = partner
303 305 .and_then(|p| colonists.get(p.0).ok())
304 306 .map(|p| format!(", partner {}", p.0.name));
@@ -311,20 +313,30 @@ fn update_tile_info(
311 313 .map(|a| format!(", age {}", a.years(*tick)))
312 314 .unwrap_or_default();
313 315 let sick = if sick { ", sick" } else { "" };
316 + let warmth = warmth
317 + .map(|w| {
318 + let cold = if w.cold() { " (cold)" } else { "" };
319 + format!(", {:.0}°C{cold}", w.0)
320 + })
321 + .unwrap_or_default();
314 322 partner.unwrap_or_default()
315 323 + &child.unwrap_or_default()
316 324 + &mood
317 325 + &age
318 326 + sick
327 + + &warmth
319 328 })
320 329 .unwrap_or_default()
321 330 })
322 331 .unwrap_or_else(|_| "-".into()),
323 332 };
324 333 let clock = format!(
325 - "{} ({})",
334 + "{} ({}), {} day {}, {:.0}°C",
326 335 format_clock(*tick),
327 - time_of_day(hour_of_day(*tick))
336 + time_of_day(hour_of_day(*tick)),
337 + season(*tick).name(),
338 + day_of_season(*tick),
339 + outdoor_temperature(*tick)
328 340 );
329 341 let speed = describe_speed(*speed, time.is_paused(), time.relative_speed());
330 342 let carried: Vec<Item> = colonists.iter().filter_map(|c| c.4.0).collect();

src/sim.rs +1 -1

@@ -7,7 +7,7 @@ use crate::actions::Action;
7 7 pub const TICKS_PER_SECOND: f64 = 60.0;
8 8 pub const TICKS_PER_HOUR: u64 = 600;
9 9 pub const HOURS_PER_DAY: u64 = 24;
10 -const START_HOUR: u64 = 6;
10 +pub const START_HOUR: u64 = 6;
11 11
12 12 pub struct SimPlugin;
13 13

src/structures.rs +8 -0

@@ -57,6 +57,14 @@ impl StructureKind {
57 57 }
58 58 }
59 59
60 + pub fn heat(self) -> f32 {
61 + match self {
62 + StructureKind::Campfire => 20.0,
63 + StructureKind::Torch => 4.0,
64 + StructureKind::Bed | StructureKind::Wall | StructureKind::Door => 0.0,
65 + }
66 + }
67 +
60 68 pub fn radius(self) -> f32 {
61 69 self.light().map_or(0.0, |(radius, _)| radius)
62 70 }

src/tilemap.rs +37 -0

@@ -45,6 +45,7 @@ impl Plugin for TilemapPlugin {
45 45 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
46 46 enum Layer {
47 47 Terrain,
48 + Snow,
48 49 Zones,
49 50 Roofs,
50 51 Plants,
@@ -54,6 +55,7 @@ impl Layer {
54 55 fn z(self) -> f32 {
55 56 match self {
56 57 Layer::Terrain => 0.0,
58 + Layer::Snow => 0.2,
57 59 Layer::Zones => 0.3,
58 60 Layer::Roofs => 0.85,
59 61 Layer::Plants => 0.5,
@@ -69,6 +71,7 @@ impl Layer {
69 71 ) -> Vec<Option<TileData>> {
70 72 match self {
71 73 Layer::Terrain => chunk_tiles(map, light, origin),
74 + Layer::Snow => snow_tiles(map, light, origin),
72 75 Layer::Zones => zone_tiles(zones, origin),
73 76 Layer::Roofs => roof_tiles(map, origin),
74 77 Layer::Plants => plant_tiles(map, light, origin),
@@ -162,9 +165,11 @@ fn spawn_chunks(
162 165 ));
163 166 let zone = images.add(stack_tileset(vec![zone_texture(), farm_texture()]));
164 167 let roof = images.add(stack_tileset(vec![roof_texture()]));
168 + let snow = images.add(stack_tileset(vec![snow_texture()]));
165 169 let chunks = (map.size + CHUNK_SIZE - 1) / CHUNK_SIZE;
166 170 for (layer, tileset) in [
167 171 (Layer::Terrain, terrain),
172 + (Layer::Snow, snow),
168 173 (Layer::Zones, zone),
169 174 (Layer::Plants, plants),
170 175 (Layer::Roofs, roof),
@@ -273,6 +278,24 @@ pub fn chunk_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Optio
273 278 .collect()
274 279 }
275 280
281 +pub fn snow_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Option<TileData>> {
282 + let cover = light.snow();
283 + chunk_positions(origin)
284 + .map(|pos| {
285 + let p = pos.as_ivec2();
286 + let land = map.get(p).is_some_and(|t| !t.is_water());
287 + (cover > 0.0 && land && !map.is_roofed(p)).then(|| {
288 + let l = light.color(pos);
289 + TileData {
290 + tileset_index: 0,
291 + color: Color::srgba(l.x, l.y, l.z, cover * jitter(pos)),
292 + ..default()
293 + }
294 + })
295 + })
296 + .collect()
297 +}
298 +
276 299 pub fn zone_tiles(zones: &Zones, origin: UVec2) -> Vec<Option<TileData>> {
277 300 chunk_positions(origin)
278 301 .map(|pos| {
@@ -366,6 +389,20 @@ fn zone_texture() -> Texture {
366 389 .collect()
367 390 }
368 391
392 +fn snow_texture() -> Texture {
393 + (0..TEXTURE_SIZE * TEXTURE_SIZE)
394 + .map(|i| {
395 + let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
396 + let speck = hash2(x, y + 7) % 7 == 0;
397 + if speck {
398 + [215, 225, 240, 255]
399 + } else {
400 + [245, 248, 255, 255]
401 + }
402 + })
403 + .collect()
404 +}
405 +
369 406 fn roof_texture() -> Texture {
370 407 (0..TEXTURE_SIZE * TEXTURE_SIZE)
371 408 .map(|i| {

tasks.md +4 -0

@@ -39,3 +39,7 @@
39 39 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
40 40
41 41 ## Planned
42 +- [ ] 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
43 +- [ ] Cold: colonists track warmth (roofs insulate, fires heat); cold drains food, slows rest, lowers mood; cold colonists warm up by fires; campfires kept when it's chilly, not only dark
44 +- [ ] Winter planning: food and wood targets cover the cold days ahead
45 +- [ ] Workshops: workbench crafts iron tools (faster work) and gold decorations (mood); demand learned like other stock